diff --git a/agents/drivers/hive-go/config.go b/agents/drivers/hive-go/config.go index 787237556b..7bc34e0ba7 100644 --- a/agents/drivers/hive-go/config.go +++ b/agents/drivers/hive-go/config.go @@ -140,6 +140,7 @@ type connectionConfig struct { } func parseConnectionConfig(params connectParams) (connectionConfig, error) { + hasStructuredEndpoint := strings.TrimSpace(params.Host) != "" config := connectionConfig{ DatabaseType: strings.ToLower(strings.TrimSpace(params.DatabaseType)), Database: strings.TrimSpace(params.Database), @@ -173,9 +174,6 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) { config.Auth = "NOSASL" config.Kerberos.Service = defaultImpalaService } - if config.Database == "" { - config.Database = defaultHiveDatabase - } if params.ConnectTimeout > 0 { config.ConnectTimeout = time.Duration(params.ConnectTimeout) * time.Second } @@ -184,45 +182,60 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) { if err != nil { return connectionConfig{}, err } - if len(parsed.endpoints) > 0 { - config.Endpoints = parsed.endpoints + if !hasStructuredEndpoint { + if config.Database == "" && parsed.database != "" { + config.Database = parsed.database + } + if config.Username == "" && parsed.username != "" { + config.Username = parsed.username + } + if config.Password == "" && parsed.password != "" { + config.Password = parsed.password + } + } + if config.Database == "" { + config.Database = defaultHiveDatabase + } + + urlSections := parseHiveParameterSections(params.URLParams) + values := urlSections.session + hiveConfs := urlSections.hiveConfs + hiveVars := urlSections.hiveVars + if hasStructuredEndpoint { + deleteHiveParameters(values, "user", "username", "password", "ssl") } else { + values = mergeHiveParameters(parsed.parameters, values) + hiveConfs = mergeHiveConfAssignments(parsed.hiveConfs, hiveConfs) + hiveVars = mergeHiveAssignments(parsed.hiveVars, hiveVars) + if value, exists := firstParameter(values, "user", "username"); exists { + config.Username = value + } + if value, exists := firstParameter(values, "password"); exists { + config.Password = value + } + } + if err := applyHiveParameters(&config, values, hiveConfs); err != nil { + return connectionConfig{}, err + } + if isZooKeeperDiscovery(config.ServiceDiscoveryMode) && len(parsed.endpoints) > 0 { + // ZooKeeper discovery needs the complete endpoint list from the JDBC URL. + config.Endpoints = parsed.endpoints + } else if host := strings.TrimSpace(params.Host); host != "" { + // DBX resolves edits and transport layers before invoking the Agent. For + // direct connections that resolved endpoint must win over the persisted URL. port := params.Port if port <= 0 { port = defaultHivePort } - if host := strings.TrimSpace(params.Host); host != "" { - for _, value := range splitEndpoints(host) { - parsedEndpoint, endpointErr := parseEndpoint(value, port) - if endpointErr != nil { - return connectionConfig{}, endpointErr - } - config.Endpoints = append(config.Endpoints, parsedEndpoint) + for _, value := range splitEndpoints(host) { + parsedEndpoint, endpointErr := parseEndpoint(value, port) + if endpointErr != nil { + return connectionConfig{}, endpointErr } + config.Endpoints = append(config.Endpoints, parsedEndpoint) } - } - if parsed.database != "" { - config.Database = parsed.database - } - if parsed.username != "" { - config.Username = parsed.username - } - if parsed.password != "" { - config.Password = parsed.password - } - - urlSections := parseHiveParameterSections(params.URLParams) - values := mergeHiveParameters(parsed.parameters, urlSections.session) - hiveConfs := mergeHiveConfAssignments(parsed.hiveConfs, urlSections.hiveConfs) - hiveVars := mergeHiveAssignments(parsed.hiveVars, urlSections.hiveVars) - if value, exists := firstParameter(values, "user", "username"); exists { - config.Username = value - } - if value, exists := firstParameter(values, "password"); exists { - config.Password = value - } - if err := applyHiveParameters(&config, values, hiveConfs); err != nil { - return connectionConfig{}, err + } else { + config.Endpoints = parsed.endpoints } applyOpenSessionVariables(&config, values, hiveConfs, hiveVars) if err := applyDelegationToken(&config, values); err != nil { @@ -251,6 +264,10 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) { return config, nil } +func isZooKeeperDiscovery(mode string) bool { + return strings.EqualFold(mode, "zookeeper") || strings.EqualFold(mode, "zookeeperha") +} + type parsedHiveConnection struct { endpoints []endpoint database string @@ -474,6 +491,17 @@ func setCaseInsensitive(values map[string]string, key, value string) { values[key] = value } +func deleteHiveParameters(values map[string]string, keys ...string) { + for existing := range values { + for _, key := range keys { + if strings.EqualFold(existing, key) { + delete(values, existing) + break + } + } + } +} + func mergeHiveAssignments(first, second map[string]string) map[string]string { result := make(map[string]string, len(first)+len(second)) for key, value := range first { diff --git a/agents/drivers/hive-go/config_test.go b/agents/drivers/hive-go/config_test.go index 5a750378d1..1e602a5853 100644 --- a/agents/drivers/hive-go/config_test.go +++ b/agents/drivers/hive-go/config_test.go @@ -176,6 +176,106 @@ func TestURLParamsOverrideConnectionString(t *testing.T) { if config.TransportMode != "http" || config.HTTPPath != "proxy" { t.Fatalf("URL params did not override connection string: %#v", config) } + if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "hive.example.com", Port: 10000}) { + t.Fatalf("resolved form endpoint did not override connection string: %#v", config.Endpoints) + } +} + +func TestResolvedDirectEndpointAndDatabasePreserveJDBCParameterSections(t *testing.T) { + config, err := parseConnectionConfig(connectParams{ + Host: "127.0.0.1", + Port: 18080, + Database: "analytics", + ConnectionString: "jdbc:hive2://old.example.com:10000/default;transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events", + URLParams: "transportMode=http;httpPath=gateway?hive.exec.dynamic.partition=true#SourceTable=events", + }) + if err != nil { + t.Fatal(err) + } + if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) { + t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints) + } + if config.Database != "analytics" { + t.Fatalf("resolved database = %q, want analytics", config.Database) + } + if config.TransportMode != "http" || config.HTTPPath != "gateway" { + t.Fatalf("JDBC session parameters were not preserved: %#v", config) + } + if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" { + t.Fatalf("JDBC hiveConfs were not preserved: %#v", config.HiveConfiguration) + } + if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" { + t.Fatalf("JDBC hiveVars were not preserved: %#v", config.HiveConfiguration) + } +} + +func TestStructuredFieldsOverridePersistedJDBCValues(t *testing.T) { + config, err := parseConnectionConfig(connectParams{ + Host: "127.0.0.1", + Port: 18080, + Username: "edited-user", + Password: "edited-password", + ConnectionString: "jdbc:hive2://old-user:old-password@old.example.com:10000/old_database;transportMode=http;auth=LDAP;ssl=true;serviceDiscoveryMode=zooKeeper?hive.exec.dynamic.partition=true#SourceTable=events", + URLParams: "user=url-user;password=url-password;ssl=true?hive.exec.dynamic.partition=true#SourceTable=events", + }) + if err != nil { + t.Fatal(err) + } + if len(config.Endpoints) != 1 || config.Endpoints[0] != (endpoint{Host: "127.0.0.1", Port: 18080}) { + t.Fatalf("unexpected resolved endpoint: %#v", config.Endpoints) + } + if config.Database != defaultHiveDatabase { + t.Fatalf("database = %q, want %q", config.Database, defaultHiveDatabase) + } + if config.Username != "edited-user" || config.Password != "edited-password" { + t.Fatalf("structured credentials were overwritten: username=%q password=%q", config.Username, config.Password) + } + if config.TransportMode != "binary" || config.Auth != "NONE" || config.ServiceDiscoveryMode != "" { + t.Fatalf("removed JDBC parameters remained active: %#v", config) + } + if config.TLSConfig != nil { + t.Fatal("disabled structured SSL was re-enabled by persisted JDBC parameters") + } + if config.HiveConfiguration["set:hiveconf:hive.exec.dynamic.partition"] != "true" { + t.Fatalf("current hiveConfs were not preserved: %#v", config.HiveConfiguration) + } + if config.HiveConfiguration["set:hivevar:SourceTable"] != "events" { + t.Fatalf("current hiveVars were not preserved: %#v", config.HiveConfiguration) + } +} + +func TestConnectionStringOnlyKeepsJDBCValues(t *testing.T) { + config, err := parseConnectionConfig(connectParams{ + ConnectionString: "jdbc:hive2://raw-user:raw-password@hive.example.com:10001/analytics;transportMode=http;ssl=true", + }) + if err != nil { + t.Fatal(err) + } + if config.Database != "analytics" || config.Username != "raw-user" || config.Password != "raw-password" { + t.Fatalf("connection-string-only fields were not preserved: %#v", config) + } + if config.TransportMode != "http" || config.TLSConfig == nil { + t.Fatalf("connection-string-only parameters were not preserved: %#v", config) + } +} + +func TestZooKeeperDiscoveryKeepsAllJDBCEndpoints(t *testing.T) { + config, err := parseConnectionConfig(connectParams{ + Host: "127.0.0.1", + Port: 12181, + Database: "analytics", + ConnectionString: "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2", + URLParams: "serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2", + }) + if err != nil { + t.Fatal(err) + } + if len(config.Endpoints) != 2 || config.Endpoints[0].Host != "zk1.example.com" || config.Endpoints[1].Host != "zk2.example.com" { + t.Fatalf("ZooKeeper discovery endpoints were not preserved: %#v", config.Endpoints) + } + if config.Database != "analytics" { + t.Fatalf("resolved database = %q, want analytics", config.Database) + } } func TestImpalaDefaultsToNoSASL(t *testing.T) { @@ -218,7 +318,8 @@ func TestImpalaLDAPHTTPSSLConfiguration(t *testing.T) { Port: 21050, Username: "alice", Password: "secret", - URLParams: "auth=LDAP;transportMode=http;httpPath=cliservice;ssl=true", + URLParams: "auth=LDAP;transportMode=http;httpPath=cliservice", + SSL: true, }) if err != nil { t.Fatal(err) diff --git a/agents/go-common/gohive/hive.go b/agents/go-common/gohive/hive.go index 7b8ce285f0..e769092f77 100644 --- a/agents/go-common/gohive/hive.go +++ b/agents/go-common/gohive/hive.go @@ -10,6 +10,7 @@ import ( "net/http/cookiejar" "net/url" "os/user" + "slices" "strconv" "strings" "sync" @@ -451,7 +452,7 @@ func innerConnect(ctx context.Context, host string, port int, auth string, } if configuration.TransportMode == "http" { - if auth == "NONE" || auth == "LDAP" || auth == "CUSTOM" { + if usesHTTPBasicAuth(auth) { httpClient, protocol, err := prepareHTTPClient(configuration) if err != nil { return nil, err @@ -469,16 +470,6 @@ func innerConnect(ctx context.Context, host string, port int, auth string, if err != nil { return nil, err } - } else if auth == "NOSASL" { - httpClient, protocol, err := prepareHTTPClient(configuration) - if err != nil { - return nil, err - } - httpOptions := thrift.THttpClientOptions{Client: httpClient} - transport, err = thrift.NewTHttpClientTransportFactoryWithOptions(hiveHTTPURL(protocol, host, port, configuration.HTTPPath), httpOptions).GetTransport(socket) - if err != nil { - return nil, err - } } else if auth == "KERBEROS" { httpClient, protocol, err := prepareHTTPClient(configuration) if err != nil { @@ -1643,14 +1634,49 @@ func safeStatus(status *hiveserver.TStatus) *hiveserver.TStatus { func hiveStatusError(action string, status *hiveserver.TStatus) error { status = safeStatus(status) + diagnostic := hiveStatusDiagnostic(status) return &Error{ - Err: fmt.Errorf("HiveServer2 error while %s: %s", action, status.String()), - Message: status.GetErrorMessage(), + Err: fmt.Errorf("HiveServer2 error while %s: %s", action, diagnostic), + Message: diagnostic, ErrorCode: int(status.GetErrorCode()), SQLState: status.GetSqlState(), } } +func hiveStatusDiagnostic(status *hiveserver.TStatus) string { + messages := make([]string, 0, len(status.GetInfoMessages())+1) + if message := strings.TrimSpace(status.GetErrorMessage()); message != "" { + messages = append(messages, message) + } + for _, info := range status.GetInfoMessages() { + message := strings.TrimSpace(info) + if message == "" || slices.Contains(messages, message) { + continue + } + messages = append(messages, message) + } + if len(messages) == 0 { + messages = append(messages, fmt.Sprintf("server returned %s without error details", status.GetStatusCode())) + } + + metadata := make([]string, 0, 2) + if sqlState := strings.TrimSpace(status.GetSqlState()); sqlState != "" { + metadata = append(metadata, "SQLState "+sqlState) + } + if status.IsSetErrorCode() { + metadata = append(metadata, fmt.Sprintf("error code %d", status.GetErrorCode())) + } + diagnostic := strings.Join(messages, "; ") + if len(metadata) > 0 { + diagnostic += " (" + strings.Join(metadata, ", ") + ")" + } + return diagnostic +} + +func usesHTTPBasicAuth(auth string) bool { + return auth == "NONE" || auth == "NOSASL" || auth == "LDAP" || auth == "CUSTOM" +} + func quoteHiveIdentifier(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" } diff --git a/agents/go-common/gohive/hive_status_error_test.go b/agents/go-common/gohive/hive_status_error_test.go new file mode 100644 index 0000000000..049aca8bfc --- /dev/null +++ b/agents/go-common/gohive/hive_status_error_test.go @@ -0,0 +1,43 @@ +package gohive + +import ( + "strings" + "testing" + + "github.com/beltran/gohive/v2/hiveserver" +) + +func TestHiveStatusErrorDoesNotDumpEmptyThriftFields(t *testing.T) { + err := hiveStatusError("opening session", &hiveserver.TStatus{StatusCode: hiveserver.TStatusCode_ERROR_STATUS}) + message := err.Error() + if message != "HiveServer2 error while opening session: server returned ERROR_STATUS without error details" { + t.Fatalf("unexpected empty-status error: %q", message) + } + for _, leakedField := range []string{"InfoMessages", "", "ErrorMessage"} { + if strings.Contains(message, leakedField) { + t.Fatalf("error leaked raw Thrift field %q: %q", leakedField, message) + } + } +} + +func TestHiveStatusErrorPreservesUsefulServerDiagnostics(t *testing.T) { + sqlState := "28000" + errorCode := int32(1001) + errorMessage := "authentication failed" + err := hiveStatusError("opening session", &hiveserver.TStatus{ + StatusCode: hiveserver.TStatusCode_ERROR_STATUS, + InfoMessages: []string{"gateway rejected the session", "authentication failed", ""}, + SqlState: &sqlState, + ErrorCode: &errorCode, + ErrorMessage: &errorMessage, + }) + + expected := "HiveServer2 error while opening session: authentication failed; gateway rejected the session (SQLState 28000, error code 1001)" + if err.Error() != expected { + t.Fatalf("unexpected diagnostic error: %q", err.Error()) + } + hiveError, ok := err.(*Error) + if !ok || hiveError.Message != "authentication failed; gateway rejected the session (SQLState 28000, error code 1001)" { + t.Fatalf("unexpected Hive error payload: %#v", err) + } +} diff --git a/agents/go-common/gohive/http_auth_test.go b/agents/go-common/gohive/http_auth_test.go index d205062cbb..c57f5a90e0 100644 --- a/agents/go-common/gohive/http_auth_test.go +++ b/agents/go-common/gohive/http_auth_test.go @@ -46,6 +46,19 @@ func TestBasicAuthRoundTripperAddsCredentialsWithoutMutatingRequest(t *testing.T } } +func TestHTTPNoSaslUsesBasicAuthLikeHiveJDBC(t *testing.T) { + for _, auth := range []string{"NONE", "NOSASL", "LDAP", "CUSTOM"} { + if !usesHTTPBasicAuth(auth) { + t.Fatalf("HTTP auth mode %q should send Basic credentials", auth) + } + } + for _, auth := range []string{"KERBEROS", "JWT", "BROWSER", "DIGEST-MD5"} { + if usesHTTPBasicAuth(auth) { + t.Fatalf("HTTP auth mode %q must not send Basic credentials", auth) + } + } +} + func TestBearerAndDelegationAuthRoundTrippers(t *testing.T) { for name, transport := range map[string]http.RoundTripper{ "jwt": &bearerAuthRoundTripper{ diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 1bd645a7b9..aaec3ff44d 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -75,6 +75,7 @@ import { ArrowLeft, ArrowDown, ArrowUp, + Check, CheckSquare, ChevronRight, CircleHelp, @@ -261,6 +262,8 @@ const agentInstallError = ref(""); const showConnectionErrorDialog = ref(false); const connectionErrorRawDetail = ref(""); const connectionErrorDetail = ref(""); +const testResultCopied = ref(false); +const connectionErrorCopied = ref(false); const editingId = ref(null); const draftTestConnectionId = ref(uuid()); const showVisibleDatabasesDialog = ref(false); @@ -1915,6 +1918,7 @@ function failAgentDriverInstall(error: unknown) { function showConnectionError(message: string) { connectionErrorRawDetail.value = message; connectionErrorDetail.value = translateBackendError(t, message); + connectionErrorCopied.value = false; showConnectionErrorDialog.value = true; } @@ -3594,6 +3598,7 @@ async function testConnection() { const runId = ++testRunId; isTesting.value = true; testResult.value = null; + testResultCopied.value = false; let config: ConnectionConfig | null = null; const submittedSourceName = form.value.name; try { @@ -4421,6 +4426,8 @@ function resetTestState() { showConnectionErrorDialog.value = false; connectionErrorRawDetail.value = ""; connectionErrorDetail.value = ""; + testResultCopied.value = false; + connectionErrorCopied.value = false; } function resetVisibleDatabaseDraftState() { @@ -4793,6 +4800,7 @@ async function copyTestResult() { if (!testResultMessage.value) return; try { await copyToClipboard(testResultMessage.value); + testResultCopied.value = true; toast(t("grid.copied")); } catch (e: any) { toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); @@ -4824,6 +4832,7 @@ async function copyConnectionErrorDetail() { if (!connectionErrorDetail.value) return; try { await copyToClipboard(connectionErrorDetail.value); + connectionErrorCopied.value = true; toast(t("grid.copied")); } catch (e: any) { toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); @@ -8464,8 +8473,9 @@ function openExternalUrl(url: string) { {{ testResultMessage }} - @@ -8550,8 +8560,9 @@ function openExternalUrl(url: string) { {{ t("toolbar.driverManager") }} diff --git a/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts b/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts new file mode 100644 index 0000000000..6a103ce7c3 --- /dev/null +++ b/apps/desktop/src/lib/common/__tests__/clipboard.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard"; + +function legacyClipboardEnvironment(activeDialog?: { appendChild: ReturnType; removeChild: ReturnType }) { + const textarea = { + value: "", + style: {}, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + }; + const body = { appendChild: vi.fn(), removeChild: vi.fn() }; + const env: ClipboardEnvironment = { + navigator: { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } }, + document: { + body, + activeElement: activeDialog ? { closest: vi.fn().mockReturnValue(activeDialog) } : undefined, + createElement: vi.fn().mockReturnValue(textarea), + execCommand: vi.fn().mockReturnValue(true), + }, + }; + return { body, env, textarea }; +} + +describe("copyToClipboard", () => { + it("keeps the legacy copy target inside the active dialog", async () => { + const dialog = { appendChild: vi.fn(), removeChild: vi.fn() }; + const { body, env, textarea } = legacyClipboardEnvironment(dialog); + + await copyToClipboard("connection failed", env); + + expect(dialog.appendChild).toHaveBeenCalledWith(textarea); + expect(dialog.removeChild).toHaveBeenCalledWith(textarea); + expect(body.appendChild).not.toHaveBeenCalled(); + expect(textarea.value).toBe("connection failed"); + }); + + it("uses the document body when no dialog is active", async () => { + const { body, env, textarea } = legacyClipboardEnvironment(); + + await copyToClipboard("copied text", env); + + expect(body.appendChild).toHaveBeenCalledWith(textarea); + expect(body.removeChild).toHaveBeenCalledWith(textarea); + }); +}); diff --git a/apps/desktop/src/lib/common/clipboard.ts b/apps/desktop/src/lib/common/clipboard.ts index 54c4ce79b9..25e296c897 100644 --- a/apps/desktop/src/lib/common/clipboard.ts +++ b/apps/desktop/src/lib/common/clipboard.ts @@ -23,10 +23,15 @@ interface ClipboardTextarea { setSelectionRange?(start: number, end: number): void; } +interface ClipboardContainer { + appendChild(node: unknown): unknown; + removeChild(node: unknown): unknown; +} + interface ClipboardDocument { - body?: { - appendChild(node: unknown): unknown; - removeChild(node: unknown): unknown; + body?: ClipboardContainer; + activeElement?: { + closest?(selector: string): ClipboardContainer | null; }; createElement(tagName: "textarea"): ClipboardTextarea; execCommand?(command: string): boolean; @@ -69,6 +74,10 @@ function recordClipboardWrite(): void { clipboardWriteRevision += 1; } +function legacyClipboardContainer(document: ClipboardDocument): ClipboardContainer | undefined { + return document.activeElement?.closest?.("[role='dialog']") ?? document.body; +} + function closestElement(target: unknown, selector: string): unknown { return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null; } @@ -149,7 +158,8 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = } const document = env.document; - if (!document?.body || !document.execCommand) { + const container = document ? legacyClipboardContainer(document) : undefined; + if (!document || !container || !document.execCommand) { throw new Error("Clipboard API is not available"); } @@ -161,7 +171,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = textarea.style.left = "-9999px"; textarea.style.opacity = "0"; - document.body.appendChild(textarea); + container.appendChild(textarea); try { textarea.focus?.(); textarea.select(); @@ -171,6 +181,6 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment = } recordClipboardWrite(); } finally { - document.body.removeChild(textarea); + container.removeChild(textarea); } } diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index 624fd8c04f..585546738f 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -257,6 +257,31 @@ function queryParamValue(params: string, key: string): string | undefined { return undefined; } +function extractHiveStructuredParams(params: string): { username?: string; password?: string; ssl: boolean; urlParams: string } { + let username: string | undefined; + let password: string | undefined; + let ssl = false; + const urlParams: string[] = []; + + for (const part of params.split(";")) { + if (!part) continue; + const [rawKey, ...rest] = part.split("="); + const key = decodeUrlPart(rawKey).trim().toLowerCase(); + const value = decodeUrlPart(rest.join("=")).trim(); + if (key === "user" || key === "username") { + username = value; + } else if (key === "password") { + password = value; + } else if (key === "ssl") { + ssl = value.toLowerCase() === "true"; + } else { + urlParams.push(part); + } + } + + return { username, password, ssl, urlParams: urlParams.join(";") }; +} + function connectionNameParam(parsed: URL): string | undefined { for (const [key, value] of parsed.searchParams) { if (key.toLowerCase() === "name") { @@ -341,6 +366,40 @@ export function connectionProfileForScheme(scheme: string, preferredProfile?: st return SCHEME_PROFILES[normalizedScheme]; } +function parseJdbcHiveUrl(source: string): ParsedConnectionUrl | null { + const match = /^jdbc:hive2:\/\/(?[^/?#;]+)(?:\/(?[^?#]*))?(?\?[^#]*)?(?#.*)?$/i.exec(source); + if (!match?.groups) return null; + + const firstHost = match.groups.hosts.split(",")[0]?.trim(); + if (!firstHost) return null; + + let endpoint: URL; + try { + endpoint = new URL(`hive2://${firstHost}`); + } catch { + return null; + } + if (!endpoint.hostname) return null; + + const [rawDatabase = "", ...paramParts] = (match.groups.path || "").split(";"); + const structured = extractHiveStructuredParams(paramParts.join(";")); + const urlParams = `${structured.urlParams}${match.groups.query || ""}${match.groups.fragment || ""}`; + + return { + dbType: "hive", + driverProfile: "hive", + driverLabel: "Apache Hive", + host: endpoint.hostname.replace(/^\[(.*)]$/, "$1"), + port: endpoint.port ? Number(endpoint.port) : 10000, + username: structured.username ?? decodeUrlPart(endpoint.username), + password: structured.password ?? decodeUrlPart(endpoint.password), + database: decodeUrlPart(rawDatabase) || undefined, + urlParams, + ssl: structured.ssl, + connectionString: source, + }; +} + function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null { const match = source.match(/^jdbc:sqlserver:\/\/([^;:/]+)(?::(\d+))?(?:;(.*))?$/i); if (!match) return null; @@ -580,6 +639,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa if (!input) { throw new Error("Connection URL is empty"); } + const jdbcHive = parseJdbcHiveUrl(input); + if (jdbcHive) return jdbcHive; const jdbcH2 = parseH2JdbcUrl(input); if (jdbcH2) return jdbcH2; const jdbcUCanAccess = parseJdbcUCanAccessUrl(input); diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs index 808d9f0e51..da2124ccb0 100644 --- a/crates/dbx-core/src/agent_connection.rs +++ b/crates/dbx-core/src/agent_connection.rs @@ -629,6 +629,31 @@ fn append_agent_url_params(base: String, params: Option<&str>) -> String { format!("{base}{separator}{params}") } +pub fn hive_uses_zookeeper_discovery(config: &ConnectionConfig) -> bool { + if !matches!(config.db_type, DatabaseType::Hive | DatabaseType::Kyuubi | DatabaseType::Impala) { + return false; + } + + if config.url_params.as_deref().is_some_and(hive_parameters_use_zookeeper_discovery) { + return true; + } + + config.host.trim().is_empty() + && config.connection_string.as_deref().is_some_and(hive_parameters_use_zookeeper_discovery) +} + +fn hive_parameters_use_zookeeper_discovery(source: &str) -> bool { + source.split([';', '?', '#', '&']).any(|part| { + let Some((key, value)) = part.split_once('=') else { + return false; + }; + let key = percent_decode_str(key.trim()).decode_utf8_lossy(); + let value = percent_decode_str(value.trim()).decode_utf8_lossy(); + key.eq_ignore_ascii_case("serviceDiscoveryMode") + && (value.eq_ignore_ascii_case("zookeeper") || value.eq_ignore_ascii_case("zookeeperha")) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -716,6 +741,25 @@ mod tests { assert_eq!(params["sessionRole"], "metadata"); } + #[test] + fn structured_hive_uses_current_url_params_for_zookeeper_detection() { + let mut cfg = config(DatabaseType::Hive, Some("default")); + cfg.connection_string = Some( + "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper".to_string(), + ); + assert!(!hive_uses_zookeeper_discovery(&cfg)); + + cfg.url_params = Some("serviceDiscoveryMode=zooKeeperHA;zooKeeperNamespace=hs2".to_string()); + assert!(hive_uses_zookeeper_discovery(&cfg)); + + cfg.url_params = Some("note=serviceDiscoveryMode=zooKeeper".to_string()); + assert!(!hive_uses_zookeeper_discovery(&cfg)); + + cfg.host.clear(); + cfg.url_params = None; + assert!(hive_uses_zookeeper_discovery(&cfg)); + } + #[test] fn mongodb_database_falls_back_to_uri_database() { let mut cfg = config(DatabaseType::MongoDb, None); diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 6088e7121f..4149298c08 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -9,10 +9,10 @@ use mysql_async::prelude::Queryable; use mysql_async::Row as MysqlRow; use crate::agent_connection::{ - agent_connect_params, agent_connect_params_with_role, h2_file_path_from_jdbc_url, is_h2_file_connection, - mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, oracle_alternate_connect_config_labels, - oracle_alternate_connect_configs, oracle_error_with_driver_hint, should_retry_mongo_with_legacy_driver, - trino_like_jdbc_connection_string, AgentSessionRole, + agent_connect_params, agent_connect_params_with_role, h2_file_path_from_jdbc_url, hive_uses_zookeeper_discovery, + is_h2_file_connection, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, + oracle_alternate_connect_config_labels, oracle_alternate_connect_configs, oracle_error_with_driver_hint, + should_retry_mongo_with_legacy_driver, trino_like_jdbc_connection_string, AgentSessionRole, }; use crate::agent_manager::{AgentManager, JavaRuntimeMode, DEFAULT_JRE_KEY}; use crate::agent_recovery::{RecoveryDecision, RecoveryPolicy, RecoveryScope}; @@ -2556,6 +2556,9 @@ impl AppState { // through one local tunnel endpoint would silently break Oracle Net routing. return Err("Oracle TNS connections cannot be combined with SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.".to_string()); } + if hive_uses_zookeeper_discovery(config) { + return Err("Hive ZooKeeper service discovery cannot be combined with SSH, proxy, or HTTP tunnel layers because discovered HiveServer2 nodes would bypass the configured transport. Remove the transport layer or use a direct HiveServer2 host and port.".to_string()); + } #[cfg(feature = "mq-admin")] if config.db_type == DatabaseType::MessageQueue @@ -8078,6 +8081,23 @@ for line in sys.stdin: let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn hive_zookeeper_discovery_rejects_static_transport_layers() { + let (state, dir) = test_app_state().await; + let mut config = mysql_config(Some("default")); + config.db_type = DatabaseType::Hive; + config.connection_string = Some( + "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper".to_string(), + ); + config.url_params = Some("serviceDiscoveryMode=zooKeeper".to_string()); + config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("hive-zk-tunnel", ""))]; + + let error = state.connection_host_port("hive-zookeeper", &config).await.unwrap_err(); + assert!(error.contains("discovered HiveServer2 nodes would bypass the configured transport")); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn resolved_transport_layers_substitutes_shared_profiles() { let (state, dir) = test_app_state().await; diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index f20b1f3358..ddca428681 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -104,10 +104,7 @@ test("does not carry typed credentials to a different URL profile", () => { test("does not carry typed credentials between driver profiles of the same database type", () => { const parsed = parseConnectionUrl("mariadb://db.example.com/app"); - const applied = applyParsedConnectionUrl( - { name: "app", db_type: "mysql", driver_profile: "mysql", username: "mysql-user", password: "mysql-secret" } as any, - parsed, - ); + const applied = applyParsedConnectionUrl({ name: "app", db_type: "mysql", driver_profile: "mysql", username: "mysql-user", password: "mysql-secret" } as any, parsed); assert.equal(applied.db_type, "mysql"); assert.equal(applied.driver_profile, "mariadb"); @@ -241,6 +238,54 @@ test("parses JDBC URLs by using the inner database URL", () => { assert.equal(mysql.urlParams, "charset=utf8mb4"); }); +test("parses Hive JDBC URLs using HTTP transport", () => { + const source = "jdbc:hive2://hive.example.com:20001/;transportMode=http;httpPath=cliservice;auth=noSasl"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.dbType, "hive"); + assert.equal(parsed.driverProfile, "hive"); + assert.equal(parsed.driverLabel, "Apache Hive"); + assert.equal(parsed.host, "hive.example.com"); + assert.equal(parsed.port, 20001); + assert.equal(parsed.database, undefined); + assert.equal(parsed.urlParams, "transportMode=http;httpPath=cliservice;auth=noSasl"); + assert.equal(parsed.connectionString, source); +}); + +test("parses Hive JDBC URLs with a database and SSL parameter", () => { + const source = "jdbc:hive2://hive.example.com/default;transportMode=http;httpPath=cliservice;ssl=true"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.host, "hive.example.com"); + assert.equal(parsed.port, 10000); + assert.equal(parsed.database, "default"); + assert.equal(parsed.urlParams, "transportMode=http;httpPath=cliservice"); + assert.equal(parsed.ssl, true); + assert.equal(parsed.connectionString, source); +}); + +test("preserves multi-host Hive ZooKeeper JDBC URLs", () => { + const source = "jdbc:hive2://zk1.example.com:2181,zk2.example.com:2181/default;serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.host, "zk1.example.com"); + assert.equal(parsed.port, 2181); + assert.equal(parsed.database, "default"); + assert.equal(parsed.urlParams, "serviceDiscoveryMode=zooKeeper;zooKeeperNamespace=hiveserver2"); + assert.equal(parsed.connectionString, source); +}); + +test("maps Hive JDBC credentials and SSL to structured fields while preserving parameter sections", () => { + const source = "jdbc:hive2://hive.example.com:10000/analytics;user=alice;password=secret;ssl=true;transportMode=http?hive.exec.dynamic.partition=true#SourceTable=events"; + const parsed = parseConnectionUrl(source); + + assert.equal(parsed.username, "alice"); + assert.equal(parsed.password, "secret"); + assert.equal(parsed.ssl, true); + assert.equal(parsed.urlParams, "transportMode=http?hive.exec.dynamic.partition=true#SourceTable=events"); + assert.equal(parsed.connectionString, source); +}); + test("parses TDengine WebSocket JDBC URLs", () => { const parsed = parseConnectionUrl("jdbc:TAOS-WS://root:taosdata@td.example.com:6041/power?timezone=UTC");