diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5dc3d4c..cc85f3793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Oracle 10G password verifier authentication. Accounts whose `password_versions` includes a 10G hash now connect successfully, matching DBeaver/JDBC/sqlplus behavior. The 10G hash is documented as legacy; rotating to a modern verifier is still recommended (#483) +- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue +- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features + ### Changed - Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR. @@ -19,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2 - Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `` instead of crashing (#965) +- Oracle connections to 23ai cloud and containerized deployments no longer fail with `uncleanShutdown` mid-handshake. OOB urgent-byte send now requires the server to advertise `TNS_ACCEPT_FLAG_CHECK_OOB`, matching python-oracledb behavior (#483) ## [0.37.0] - 2026-05-01 diff --git a/Plugins/OracleDriverPlugin/OracleConnection.swift b/Plugins/OracleDriverPlugin/OracleConnection.swift index 4471c7396..04b7754cd 100644 --- a/Plugins/OracleDriverPlugin/OracleConnection.swift +++ b/Plugins/OracleDriverPlugin/OracleConnection.swift @@ -18,11 +18,36 @@ private let osLogger = Logger(subsystem: "com.TablePro", category: "OracleConnec // MARK: - Error Types struct OracleError: Error { + enum Category: Sendable, Equatable { + case generic + case notConnected + case connectionFailed + case queryFailed + case authVerifierUnsupported(flag: String) + case authVersionNotSupported + case authConnectionDropped + } + let message: String + let category: Category + + init(message: String, category: Category = .generic) { + self.message = message + self.category = category + } - static let notConnected = OracleError(message: String(localized: "Not connected to database")) - static let connectionFailed = OracleError(message: String(localized: "Failed to establish connection")) - static let queryFailed = OracleError(message: String(localized: "Query execution failed")) + static let notConnected = OracleError( + message: String(localized: "Not connected to database"), + category: .notConnected + ) + static let connectionFailed = OracleError( + message: String(localized: "Failed to establish connection"), + category: .connectionFailed + ) + static let queryFailed = OracleError( + message: String(localized: "Query execution failed"), + category: .queryFailed + ) } extension OracleError: PluginDriverError { @@ -147,11 +172,26 @@ final class OracleConnectionWrapper: @unchecked Sendable { } catch let sqlError as OracleSQLError { let detail = sqlError.serverInfo?.message ?? sqlError.description osLogger.error("Oracle connection failed: \(detail)") - throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + throw OracleError(message: detail, category: classifyConnectError(sqlError)) } catch { let detail = String(describing: error) osLogger.error("Oracle connection failed: \(detail)") - throw OracleError(message: "Failed to connect to \(host):\(port)/\(service): \(detail)") + throw OracleError(message: detail, category: .connectionFailed) + } + } + + private func classifyConnectError(_ error: OracleSQLError) -> OracleError.Category { + let codeDescription = error.code.description + if codeDescription.hasPrefix("unsupportedVerifierType") { + return .authVerifierUnsupported(flag: codeDescription) + } + switch codeDescription { + case "uncleanShutdown": + return .authConnectionDropped + case "serverVersionNotSupported": + return .authVersionNotSupported + default: + return .connectionFailed } } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index ea08b096f..d159e25fc 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -7,7 +7,7 @@ import Foundation import os import TableProPluginKit -final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { +final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnosticProvider { static let pluginName = "Oracle Driver" static let pluginVersion = "1.0.0" static let pluginDescription = "Oracle Database support via OracleNIO" @@ -16,7 +16,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Oracle" static let databaseDisplayName = "Oracle" static let iconName = "oracle-icon" - static let defaultPort = 1521 + static let defaultPort = 1_521 static let additionalConnectionFields: [ConnectionField] = [ ConnectionField(id: "oracleServiceName", label: "Service Name", placeholder: "ORCL") ] @@ -1104,6 +1104,51 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return raw.replacingOccurrences(of: "'", with: "''") } + func diagnose(error: Error) -> PluginDiagnostic? { + guard let oracleError = error as? OracleError else { return nil } + let issuesURL = URL(string: "https://github.com/TableProApp/TablePro/issues") + switch oracleError.category { + case .authVerifierUnsupported(let flag): + return PluginDiagnostic( + title: String(localized: "Unsupported Password Verifier"), + message: oracleError.message, + suggestedActions: [ + String(localized: "Verify the user account exists and the password is correct."), + String(localized: "Ask your DBA to confirm the user has an 11G or 12C password verifier (SELECT password_versions FROM dba_users WHERE username = '')."), + String(localized: "If the verifier is brand-new (e.g. 23ai), file an issue with the verifier flag below.") + ], + diagnosticInfo: [ + DiagnosticEntry(label: "Verifier flag", value: flag) + ], + supportURL: issuesURL + ) + case .authConnectionDropped: + return PluginDiagnostic( + title: String(localized: "Connection Dropped During Handshake"), + message: oracleError.message, + suggestedActions: [ + String(localized: "If the same connection works in DBeaver or sqlplus, this is likely an OOB compatibility issue with cloud-hosted Oracle."), + String(localized: "TablePro 1.2.0 already gates OOB on the server flag, so most cases are resolved. If you still hit this, file an issue."), + String(localized: "Try disabling SSH tunnel or load balancer firewall rules between client and server.") + ], + supportURL: URL(string: "https://github.com/TableProApp/TablePro/issues/483") + ) + case .authVersionNotSupported: + return PluginDiagnostic( + title: String(localized: "Server Version Not Supported"), + message: oracleError.message, + suggestedActions: [ + String(localized: "TablePro requires Oracle 12c or later via the OracleNIO Swift driver."), + String(localized: "Check the user account's password_versions; only 10G, 11G, and 12C are supported."), + String(localized: "Rotate the password under modern auth if password_versions contains an unrecognized verifier.") + ], + supportURL: issuesURL + ) + case .generic, .notConnected, .connectionFailed, .queryFailed: + return nil + } + } + private static let fromTableRegex = try? NSRegularExpression( pattern: #"FROM\s+(?:"([^"]+)"|(\w+))"#, options: .caseInsensitive diff --git a/Plugins/TableProPluginKit/PluginDiagnostic.swift b/Plugins/TableProPluginKit/PluginDiagnostic.swift new file mode 100644 index 000000000..d5b73b6b3 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginDiagnostic.swift @@ -0,0 +1,42 @@ +// +// PluginDiagnostic.swift +// TableProPluginKit +// + +import Foundation + +public struct PluginDiagnostic: Sendable, Equatable { + public let title: String + public let message: String + public let suggestedActions: [String] + public let diagnosticInfo: [DiagnosticEntry] + public let supportURL: URL? + + public init( + title: String, + message: String, + suggestedActions: [String] = [], + diagnosticInfo: [DiagnosticEntry] = [], + supportURL: URL? = nil + ) { + self.title = title + self.message = message + self.suggestedActions = suggestedActions + self.diagnosticInfo = diagnosticInfo + self.supportURL = supportURL + } +} + +public struct DiagnosticEntry: Sendable, Equatable { + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } +} + +public protocol PluginDiagnosticProvider: AnyObject, Sendable { + func diagnose(error: Error) -> PluginDiagnostic? +} diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 76da6311d..af10b330e 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -4042,7 +4042,7 @@ repositoryURL = "https://github.com/TableProApp/oracle-nio"; requirement = { kind = revision; - revision = f343a0db14aba73e50a6f93bd981d3b07a61c6d4; + revision = 7c01c8ff2e13794650719ebfa0294aa4281bbdd8; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4407295fb..d001c8ce9 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,7 +42,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/TableProApp/oracle-nio", "state" : { - "revision" : "f343a0db14aba73e50a6f93bd981d3b07a61c6d4" + "revision" : "7c01c8ff2e13794650719ebfa0294aa4281bbdd8" } }, { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index b9796bf4b..d15d329d1 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -473,6 +473,12 @@ final class PluginManager { return entry } + func diagnose(error: Error, for type: DatabaseType) -> PluginDiagnostic? { + guard let driver = driverPlugins[type.pluginTypeId] else { return nil } + guard let provider = driver as? PluginDiagnosticProvider else { return nil } + return provider.diagnose(error: error) + } + func replaceExistingPlugin(bundleId: String) { guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return } unregisterCapabilities(pluginId: bundleId) diff --git a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift index 40a3054e0..1cae2f5b6 100644 --- a/TablePro/Views/Connection/ConnectionFormView+Helpers.swift +++ b/TablePro/Views/Connection/ConnectionFormView+Helpers.swift @@ -91,10 +91,17 @@ extension ConnectionFormView { } func loadConnectionData() { + let connectionFormLog = Logger(subsystem: "com.TablePro", category: "ConnectionForm") + connectionFormLog.debug( + "[trace] loadConnectionData connectionId=\(self.connectionId?.uuidString ?? "nil", privacy: .public) isNew=\(self.connectionId == nil)" + ) sshState.profiles = SSHProfileStorage.shared.loadProfiles() if let id = connectionId, let existing = storage.loadConnections().first(where: { $0.id == id }) { + connectionFormLog.debug( + "[trace] loadConnectionData found existing id=\(existing.id.uuidString, privacy: .public) name='\(existing.name, privacy: .public)' promptForPassword=\(existing.promptForPassword)" + ) originalConnection = existing name = existing.name host = existing.host @@ -162,6 +169,13 @@ extension ConnectionFormView { // Load connection password from Keychain if let savedPassword = storage.loadPassword(for: existing.id) { password = savedPassword + connectionFormLog.debug( + "[trace] loadConnectionData password populated length=\(savedPassword.count)" + ) + } else { + connectionFormLog.debug( + "[trace] loadConnectionData password NOT populated (loadPassword returned nil)" + ) } } Task { @MainActor in @@ -486,6 +500,10 @@ extension ConnectionFormView { testSucceeded = false if case PluginError.pluginNotInstalled = error { pluginInstallConnection = testConn + } else if let item = PluginDiagnosticItem.classify( + error: error, connection: testConn, username: finalUsername + ) { + pluginDiagnostic = item } else { AlertHelper.showErrorSheet( title: String(localized: "Connection Test Failed"), diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 75af777c4..e6ccc2b85 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -129,6 +129,8 @@ struct ConnectionFormView: View { @State var isInstallingPlugin: Bool = false @State var pluginInstallError: String? + @State var pluginDiagnostic: PluginDiagnosticItem? + // Tab selection @State var selectedTab: FormTab = .general @@ -194,6 +196,11 @@ struct ConnectionFormView: View { .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in connectAfterInstall(connection) } + .sheet(item: $pluginDiagnostic) { item in + PluginDiagnosticSheet(item: item) { + pluginDiagnostic = nil + } + } .onChange(of: pgpassTrigger) { _, _ in updatePgpassStatus() } .onChange(of: usePgpass) { _, newValue in if newValue { promptForPassword = false } } } diff --git a/TablePro/Views/Connection/PluginDiagnosticSheet.swift b/TablePro/Views/Connection/PluginDiagnosticSheet.swift new file mode 100644 index 000000000..13fc2f674 --- /dev/null +++ b/TablePro/Views/Connection/PluginDiagnosticSheet.swift @@ -0,0 +1,122 @@ +// +// PluginDiagnosticSheet.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +struct PluginDiagnosticItem: Identifiable, Equatable { + let id = UUID() + let diagnostic: PluginDiagnostic + let connectionTarget: String + let username: String + + static func classify( + error: Error, + connection: DatabaseConnection, + username: String + ) -> PluginDiagnosticItem? { + guard let diagnostic = PluginManager.shared.diagnose(error: error, for: connection.type) else { + return nil + } + return PluginDiagnosticItem( + diagnostic: diagnostic, + connectionTarget: "\(connection.host):\(connection.port)/\(connection.database)", + username: username + ) + } +} + +struct PluginDiagnosticSheet: View { + let item: PluginDiagnosticItem + let onDismiss: () -> Void + + private static let issuesURL = URL(string: "https://github.com/TableProApp/TablePro/issues") + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(item.diagnostic.title, systemImage: "exclamationmark.triangle.fill") + .font(.headline) + .foregroundStyle(.primary) + + Text(item.diagnostic.message) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if !item.diagnostic.suggestedActions.isEmpty { + Divider() + actionList + } + + Divider() + diagnosticBlock + + HStack { + Button(String(localized: "Copy Diagnostic Info")) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnosticText, forType: .string) + } + if item.diagnostic.supportURL != nil || Self.issuesURL != nil { + Button(String(localized: "Open Issue Tracker")) { + let url = item.diagnostic.supportURL ?? Self.issuesURL + if let url { + NSWorkspace.shared.open(url) + } + } + } + Spacer() + Button(String(localized: "Close"), action: onDismiss) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 540) + } + + private var actionList: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Suggested Actions")) + .font(.subheadline.weight(.semibold)) + ForEach(Array(item.diagnostic.suggestedActions.enumerated()), id: \.offset) { index, action in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(index + 1).") + .foregroundStyle(.tertiary) + .monospacedDigit() + Text(action) + .fixedSize(horizontal: false, vertical: true) + } + .font(.callout) + } + } + } + + private var diagnosticBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "Diagnostic Info")) + .font(.subheadline.weight(.semibold)) + Text(diagnosticText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + } + + private var diagnosticText: String { + var lines = [ + "Target: \(item.connectionTarget)", + "User: \(item.username)", + "Error: \(item.diagnostic.message)" + ] + for entry in item.diagnostic.diagnosticInfo { + lines.append("\(entry.label): \(entry.value)") + } + return lines.joined(separator: "\n") + } +} diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 91fedc3a1..7b0bf91d0 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -94,6 +94,28 @@ ORDER BY table_name; **Authentication**: Username/password only (no OS auth or wallet). Create user: `CREATE USER app_user IDENTIFIED BY "Password1!"; GRANT CREATE SESSION, SELECT ANY TABLE TO app_user;` +## Auth Compatibility + +TablePro authenticates via `OracleNIO`, a pure-Swift implementation of the Oracle TNS wire protocol (no Oracle Instant Client needed). + +| `dba_users.password_versions` | Status | +|---|---| +| `12C` only (recommended) | ✓ Supported | +| `11G 12C` | ✓ Supported | +| `10G 11G 12C` | ✓ Supported | +| `10G 11G` (legacy migration) | ✓ Supported | +| `10G` only (deprecated) | ✓ Supported, but consider rotation | +| External / Kerberos / LDAP-managed | ✗ Not supported. File an issue. | + +10G hash is supported for compatibility with legacy environments. It uses DES-based hashing without modern salting and is deprecated by Oracle. We recommend rotating affected accounts under modern auth so `password_versions` contains only `11G` or `12C`: + +```sql +ALTER USER IDENTIFIED BY ; +SELECT username, password_versions FROM dba_users WHERE username = ''; +``` + +If the connection fails with the dialog "Server Version Not Supported" or "Unsupported Password Verifier", check `password_versions` first. + ## Column Type Support | Oracle type | Display |