diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index bd65413ed8..ecf8917703 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -61,6 +61,7 @@ import { Settings2, GitBranch, Sparkles, + Link2, } from "@lucide/vue"; import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; @@ -70,7 +71,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { useToast } from "@/composables/useToast"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; -import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; +import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; import * as api from "@/lib/backend/api"; import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri"; import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; @@ -192,6 +193,7 @@ import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from import { supportsScheduledDatabaseBackup } from "@/lib/backup/scheduledDatabaseBackup"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { buildConnectionUrlCopy, CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS, connectionUrlCopyFormats, type ConnectionUrlCopyFormat } from "@/lib/connection/connectionUrlBuilder"; import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory"; import { savedSqlClipboardFileIds, savedSqlPasteTargetForNode } from "@/lib/savedSql/savedSqlClipboard"; import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport"; @@ -2214,6 +2216,64 @@ function copyNameMenuItem(): ContextMenuItem { return { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }; } +const CONNECTION_URL_COPY_LABEL_KEYS: Record = { + url: "contextMenu.copyConnectionUrl", + urlWithPassword: "contextMenu.copyConnectionUrlWithPassword", + jdbcUrl: "contextMenu.copyJdbcUrl", + jdbcUrlWithCredentials: "contextMenu.copyJdbcUrlWithCredentials", + hostPort: "contextMenu.copyHostPort", + dsn: "contextMenu.copyDsn", + dsnWithPassword: "contextMenu.copyDsnWithPassword", + psqlCommand: "contextMenu.copyPsqlCommand", +}; + +/** + * "Copy Connection Info" submenu for connection/database nodes: standard URL, + * JDBC URL, host:port, libpq DSN and psql command, trimmed to what the + * connection's dialect actually supports. The primary entries never embed the + * stored password; password-inclusive entries say so in their label and are + * gated behind a confirmation dialog. `databaseOverride` lets a database node + * copy the URL for that specific database instead of the default one. + */ +function connectionUrlCopyMenuItem(databaseOverride?: string): ContextMenuItem | null { + const node = activeNode.value; + const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined; + if (!config) return null; + const formats = connectionUrlCopyFormats(config); + if (formats.length === 0) return null; + return { + label: t("contextMenu.copyConnectionInfo"), + icon: Link2, + children: formats.map((format) => ({ + label: t(CONNECTION_URL_COPY_LABEL_KEYS[format]), + action: () => copyConnectionUrlFormat(config, format, databaseOverride), + })), + }; +} + +const showCopyConnectionSecretConfirm = shallowRef(false); +let pendingConnectionSecretCopy: { config: ConnectionConfig; format: ConnectionUrlCopyFormat; databaseOverride?: string } | null = null; + +function copyConnectionUrlFormat(config: ConnectionConfig, format: ConnectionUrlCopyFormat, databaseOverride?: string) { + if (CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS.has(format)) { + pendingConnectionSecretCopy = { config, format, databaseOverride }; + showCopyConnectionSecretConfirm.value = true; + return; + } + return performConnectionUrlCopy(config, format, databaseOverride); +} + +async function performConnectionUrlCopy(config: ConnectionConfig, format: ConnectionUrlCopyFormat, databaseOverride?: string) { + const text = buildConnectionUrlCopy(config, format, { database: databaseOverride }); + if (!text) return; + try { + await copyToClipboard(text); + toast(t("connection.copied"), 2000); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } +} + async function copyCustomTypeDdl() { const node = createSidebarActionTarget(activeNode.value); if (!node.connectionId || !node.database) return; @@ -4653,6 +4713,19 @@ routeDangerDialog(showDeleteSavedSqlConfirm, () => }), ); +routeDangerDialog(showCopyConnectionSecretConfirm, () => + dangerRequest({ + title: t("contextMenu.copyPasswordConfirmTitle"), + message: t("contextMenu.copyPasswordConfirmMessage"), + confirmLabel: t("contextMenu.copyPasswordConfirmAction"), + confirm: () => { + const pending = pendingConnectionSecretCopy; + pendingConnectionSecretCopy = null; + if (pending) return performConnectionUrlCopy(pending.config, pending.format, pending.databaseOverride); + }, + }), +); + routeDangerDialog(showEmptyTableConfirm, () => dangerRequest({ title: t("contextMenu.confirmEmptyTableTitle"), @@ -5347,6 +5420,8 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean } items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + const connectionUrlCopyMenu = connectionUrlCopyMenuItem(); + if (connectionUrlCopyMenu) items.push(connectionUrlCopyMenu); items.push({ label: "", separator: true }); const supportsQueryActions = supportsConnectionQueryActions(currentDatabaseType()); const supportsAiContext = supportsAiAssistantContext(currentDatabaseType()); @@ -5565,6 +5640,8 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.unshift({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug }); } items.push(copyNameMenuItem()); + const databaseUrlCopyMenu = connectionUrlCopyMenuItem(node.database || undefined); + if (databaseUrlCopyMenu) items.push(databaseUrlCopyMenu); items.push({ label: "", separator: true }); if (canOpenObjectBrowser.value) { items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties }); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 08db06c5e1..a9ca088cec 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -3570,6 +3570,18 @@ export default withEnglishFallback({ schemaCommentPlaceholder: "Sxemin şərhini daxil edin...", schemaCommentSaving: "Yadda saxlanılır...", editSchemaCommentSuccess: '"{name}" sxeminin şərhi yeniləndi', + copyConnectionInfo: "Qoşulma məlumatını kopyala", + copyConnectionUrl: "Qoşulma URL-i", + copyConnectionUrlWithPassword: "Qoşulma URL-i (parol ilə)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL (istifadəçi adı və parol ilə)", + copyHostPort: "Host:port", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (parol ilə)", + copyPsqlCommand: "psql əmri", + copyPasswordConfirmTitle: "Parol ilə kopyala", + copyPasswordConfirmMessage: "Kopyalanan mətndə saxlanılan parol açıq şəkildə olacaq. Davam edilsin?", + copyPasswordConfirmAction: "Kopyala", }, visibleDatabases: { title: "Görünən verilənlər bazaları", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5b8d69856f..18c541c0e1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3229,6 +3229,18 @@ export default { fullPath: "Full Path", copyFinalProxyPort: "Copy Final Proxy Port", finalProxyPortCopied: "Final proxy port {port} copied", + copyConnectionInfo: "Copy Connection Info", + copyConnectionUrl: "Connection URL", + copyConnectionUrlWithPassword: "Connection URL (with password)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL (with credentials)", + copyHostPort: "Host:Port", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (with password)", + copyPsqlCommand: "psql Command", + copyPasswordConfirmTitle: "Copy with Password", + copyPasswordConfirmMessage: "The copied text will contain the stored password in plain text. Continue?", + copyPasswordConfirmAction: "Copy", setDefaultDatabase: "Set as Default Database", clearDefaultDatabase: "Clear Default Database", setDefaultSchema: "Set as Default Schema", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5cad5c7f7d..e53c963c49 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3451,6 +3451,18 @@ export default withEnglishFallback({ editObject: "Editar objeto", createEvent: "Crear evento", viewDependencies: "Ver dependencias", + copyConnectionInfo: "Copiar información de conexión", + copyConnectionUrl: "URL de conexión", + copyConnectionUrlWithPassword: "URL de conexión (con contraseña)", + copyJdbcUrl: "URL JDBC", + copyJdbcUrlWithCredentials: "URL JDBC (con usuario y contraseña)", + copyHostPort: "Host:puerto", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (con contraseña)", + copyPsqlCommand: "Comando psql", + copyPasswordConfirmTitle: "Copiar con contraseña", + copyPasswordConfirmMessage: "El texto copiado contendrá la contraseña guardada en texto sin formato. ¿Continuar?", + copyPasswordConfirmAction: "Copiar", }, visibleDatabases: { title: "Bases de datos visibles", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b25aaa74c6..e212a26765 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3449,6 +3449,18 @@ export default withEnglishFallback({ editObject: "Modifica oggetto", createEvent: "Nuovo evento", viewDependencies: "Visualizza dipendenze", + copyConnectionInfo: "Copia informazioni di connessione", + copyConnectionUrl: "URL di connessione", + copyConnectionUrlWithPassword: "URL di connessione (con password)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL (con nome utente e password)", + copyHostPort: "Host:porta", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (con password)", + copyPsqlCommand: "Comando psql", + copyPasswordConfirmTitle: "Copia con password", + copyPasswordConfirmMessage: "Il testo copiato conterrà la password salvata in chiaro. Continuare?", + copyPasswordConfirmAction: "Copia", }, visibleDatabases: { title: "Database Visibili", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a9467478f0..e974cabb5c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3475,6 +3475,18 @@ export default withEnglishFallback({ editObject: "オブジェクトを編集", createEvent: "イベントを作成", viewDependencies: "依存関係を表示", + copyConnectionInfo: "接続情報をコピー", + copyConnectionUrl: "接続URL", + copyConnectionUrlWithPassword: "接続URL(パスワードを含む)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL(アカウントとパスワードを含む)", + copyHostPort: "ホスト:ポート", + copyDsn: "DSN(key=value)", + copyDsnWithPassword: "DSN(パスワードを含む)", + copyPsqlCommand: "psql コマンド", + copyPasswordConfirmTitle: "パスワードを含めてコピー", + copyPasswordConfirmMessage: "コピーされるテキストには保存済みのパスワードが平文で含まれます。続行しますか?", + copyPasswordConfirmAction: "コピー", }, visibleDatabases: { title: "表示するデータベース", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3b54734777..c0eeeefd3c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3328,6 +3328,18 @@ export default withEnglishFallback({ refreshDdlOnOpen: "열 때마다 새로 고침", refreshDdlOnOpenHint: "켜면 열 때마다 데이터베이스에서 DDL을 다시 불러오고, 끄면 캐시된 데이터를 우선 사용합니다", viewDependencies: "의존 관계 보기", + copyConnectionInfo: "연결 정보 복사", + copyConnectionUrl: "연결 URL", + copyConnectionUrlWithPassword: "연결 URL(비밀번호 포함)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL(계정 및 비밀번호 포함)", + copyHostPort: "호스트:포트", + copyDsn: "DSN(key=value)", + copyDsnWithPassword: "DSN(비밀번호 포함)", + copyPsqlCommand: "psql 명령어", + copyPasswordConfirmTitle: "비밀번호 포함 복사", + copyPasswordConfirmMessage: "복사되는 텍스트에 저장된 비밀번호가 평문으로 포함됩니다. 계속할까요?", + copyPasswordConfirmAction: "복사", }, visibleDatabases: { title: "표시할 데이터베이스", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ea696c63d9..c6028962d0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3451,6 +3451,18 @@ export default withEnglishFallback({ editObject: "Editar objeto", createEvent: "Criar evento", viewDependencies: "Ver dependências", + copyConnectionInfo: "Copiar informações de conexão", + copyConnectionUrl: "URL de conexão", + copyConnectionUrlWithPassword: "URL de conexão (com senha)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL (com usuário e senha)", + copyHostPort: "Host:porta", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (com senha)", + copyPsqlCommand: "Comando psql", + copyPasswordConfirmTitle: "Copiar com senha", + copyPasswordConfirmMessage: "O texto copiado conterá a senha armazenada em texto simples. Continuar?", + copyPasswordConfirmAction: "Copiar", }, visibleDatabases: { title: "Bancos de dados visíveis", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 62facee42c..e6fab2d079 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -3542,6 +3542,18 @@ export default withEnglishFallback({ schemaCommentPlaceholder: "Şema açıklamasını girin...", schemaCommentSaving: "Kaydediliyor...", editSchemaCommentSuccess: '"{name}" şemasının açıklaması güncellendi', + copyConnectionInfo: "Bağlantı bilgilerini kopyala", + copyConnectionUrl: "Bağlantı URL'si", + copyConnectionUrlWithPassword: "Bağlantı URL'si (parolalı)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL (kullanıcı adı ve parolalı)", + copyHostPort: "Ana bilgisayar:port", + copyDsn: "DSN (key=value)", + copyDsnWithPassword: "DSN (parolalı)", + copyPsqlCommand: "psql komutu", + copyPasswordConfirmTitle: "Parolayla kopyala", + copyPasswordConfirmMessage: "Kopyalanan metin depolanan parolayı düz metin olarak içerecek. Devam edilsin mi?", + copyPasswordConfirmAction: "Kopyala", }, visibleDatabases: { title: "Görünür Veritabanları", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d83f36591a..6159c1fd5e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3152,6 +3152,18 @@ export default withEnglishFallback({ fullPath: "完整路径", copyFinalProxyPort: "复制最终代理端口", finalProxyPortCopied: "最终代理端口 {port} 已复制", + copyConnectionInfo: "复制连接信息", + copyConnectionUrl: "连接 URL", + copyConnectionUrlWithPassword: "连接 URL(含密码)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL(含账号密码)", + copyHostPort: "主机:端口", + copyDsn: "DSN(key=value)", + copyDsnWithPassword: "DSN(含密码)", + copyPsqlCommand: "psql 命令", + copyPasswordConfirmTitle: "复制含密码的连接信息", + copyPasswordConfirmMessage: "复制的内容将包含明文密码,任何能读取剪贴板的程序或用户都可以看到。是否继续?", + copyPasswordConfirmAction: "继续复制", setDefaultDatabase: "设为默认数据库", clearDefaultDatabase: "取消默认数据库", setDefaultSchema: "设为默认 Schema", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e3da7633d6..485e328fa7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3448,6 +3448,18 @@ export default withEnglishFallback({ editObject: "編輯物件", createEvent: "新建事件", collapseAll: "全部摺疊", + copyConnectionInfo: "複製連線資訊", + copyConnectionUrl: "連線 URL", + copyConnectionUrlWithPassword: "連線 URL(含密碼)", + copyJdbcUrl: "JDBC URL", + copyJdbcUrlWithCredentials: "JDBC URL(含帳號密碼)", + copyHostPort: "主機:連接埠", + copyDsn: "DSN(key=value)", + copyDsnWithPassword: "DSN(含密碼)", + copyPsqlCommand: "psql 命令", + copyPasswordConfirmTitle: "複製含密碼的連線資訊", + copyPasswordConfirmMessage: "複製的內容將包含明文密碼,任何能存取剪貼簿的程式或使用者都可以看到。是否繼續?", + copyPasswordConfirmAction: "繼續複製", }, visibleDatabases: { title: "顯示資料庫", diff --git a/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts new file mode 100644 index 0000000000..0fb2bc6e30 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionUrlBuilder.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { buildConnectionUrlCopy, CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS, connectionSupportsUrlCopy, connectionUrlCopyFormats, type ConnectionUrlCopyConfig } from "@/lib/connection/connectionUrlBuilder"; + +function config(overrides: Partial): ConnectionUrlCopyConfig { + return { + db_type: "postgres", + host: "db.example.com", + port: 5432, + username: "app_user", + password: "secret", + database: "appdb", + url_params: "", + ssl: false, + ...overrides, + }; +} + +describe("connectionUrlCopyFormats", () => { + it("lists all formats for a PostgreSQL connection with a stored password", () => { + expect(connectionUrlCopyFormats(config({}))).toEqual(["url", "urlWithPassword", "jdbcUrl", "jdbcUrlWithCredentials", "hostPort", "dsn", "dsnWithPassword", "psqlCommand"]); + }); + + it("drops every password-inclusive format when no password is stored", () => { + const formats = connectionUrlCopyFormats(config({ password: "" })); + for (const format of CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS) { + expect(formats).not.toContain(format); + } + expect(formats).toEqual(["url", "jdbcUrl", "hostPort", "dsn", "psqlCommand"]); + }); + + it("hides everything for file-based connections", () => { + expect(connectionSupportsUrlCopy(config({ db_type: "sqlite", host: "/data/app.db", port: 0 }))).toBe(false); + expect(connectionUrlCopyFormats(config({ db_type: "duckdb", host: "/data/app.duckdb", port: 0 }))).toEqual([]); + expect(connectionUrlCopyFormats(config({ db_type: "h2", host: "/data/app.mv.db", port: 0 }))).toEqual([]); + }); + + it("keeps H2 server mode available through its explicit connection string", () => { + const formats = connectionUrlCopyFormats(config({ db_type: "h2", host: "db.example.com", port: 9092, connection_string: "jdbc:h2:tcp://db.example.com:9092/~/test" })); + expect(formats).toEqual(["url", "hostPort"]); + }); + + it("hides URL copy for service-registry and message-queue connections", () => { + expect(connectionUrlCopyFormats(config({ db_type: "nacos", host: "nacos.example.com", port: 8848 }))).toEqual([]); + expect(connectionUrlCopyFormats(config({ db_type: "mq", host: "kafka.example.com", port: 9092 }))).toEqual([]); + }); + + it("offers only JDBC formats for hive-family connections", () => { + const formats = connectionUrlCopyFormats(config({ db_type: "hive", host: "hive.example.com", port: 10000, database: "dw" })); + expect(formats).toEqual(["jdbcUrl", "jdbcUrlWithCredentials", "hostPort"]); + }); +}); + +describe("buildConnectionUrlCopy standard URL", () => { + it("builds a password-free PostgreSQL URL for the primary item", () => { + expect(buildConnectionUrlCopy(config({}), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb"); + }); + + it("embeds credentials only in the explicit with-password variant", () => { + expect(buildConnectionUrlCopy(config({}), "urlWithPassword")).toBe("postgresql://app_user:secret@db.example.com:5432/appdb"); + }); + + it("percent-encodes special characters in credentials and database", () => { + const text = buildConnectionUrlCopy(config({ username: "user@corp", password: "p@ss:w/ord#", database: "app db" }), "urlWithPassword"); + expect(text).toBe("postgresql://user%40corp:p%40ss%3Aw%2Ford%23@db.example.com:5432/app%20db"); + }); + + it("appends url_params and adds sslmode when ssl is enabled", () => { + expect(buildConnectionUrlCopy(config({ url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc"); + expect(buildConnectionUrlCopy(config({ ssl: true, url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc&sslmode=require"); + expect(buildConnectionUrlCopy(config({ ssl: true, url_params: "sslmode=verify-full" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?sslmode=verify-full"); + }); + + it("honours the database override from a database tree node", () => { + expect(buildConnectionUrlCopy(config({}), "url", { database: "reporting" })).toBe("postgresql://app_user@db.example.com:5432/reporting"); + }); + + it("uses rediss:// when TLS is enabled for Redis", () => { + const redis = config({ db_type: "redis", host: "cache.example.com", port: 6379, username: "", database: "0", ssl: true }); + expect(buildConnectionUrlCopy(redis, "url")).toBe("rediss://cache.example.com:6379/0"); + expect(buildConnectionUrlCopy(redis, "urlWithPassword")).toBe("rediss://:secret@cache.example.com:6379/0"); + }); + + it("maps the MySQL family profiles to their own schemes", () => { + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "url")).toBe("mysql://app_user@db.example.com:3306/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, driver_profile: "mariadb" }), "urlWithPassword")).toBe("mariadb://app_user:secret@db.example.com:3306/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 2883, driver_profile: "oceanbase" }), "urlWithPassword")).toBe("oceanbase://app_user:secret@db.example.com:2883/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "doris", port: 9030 }), "urlWithPassword")).toBe("mysql://app_user:secret@db.example.com:9030/appdb"); + }); + + it("keeps multi-host endpoints verbatim without an extra port suffix", () => { + const text = buildConnectionUrlCopy(config({ db_type: "gaussdb", host: "h1.example.com:5432,h2.example.com:5432" }), "urlWithPassword"); + expect(text).toBe("postgresql://app_user:secret@h1.example.com:5432,h2.example.com:5432/appdb"); + }); + + it("brackets bare IPv6 hosts", () => { + expect(buildConnectionUrlCopy(config({ host: "fe80::1" }), "urlWithPassword")).toBe("postgresql://app_user:secret@[fe80::1]:5432/appdb"); + }); + + it("redacts explicit connection strings unless the with-password variant is requested", () => { + const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:dremio:direct=drill.example.com:31010;user=analyst;password=s3cret" }); + expect(buildConnectionUrlCopy(jdbcConfig, "url")).toBe("jdbc:dremio:direct=drill.example.com:31010;user=analyst;password=***"); + expect(buildConnectionUrlCopy(jdbcConfig, "urlWithPassword")).toBe(jdbcConfig.connection_string); + // Credentials embedded in the explicit string count as a copyable secret + // even though no password field is stored. + expect(connectionUrlCopyFormats(jdbcConfig)).toEqual(["url", "urlWithPassword"]); + }); +}); + +describe("buildConnectionUrlCopy JDBC URL", () => { + it("builds a generic PostgreSQL JDBC URL", () => { + expect(buildConnectionUrlCopy(config({}), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb"); + expect(buildConnectionUrlCopy(config({}), "jdbcUrlWithCredentials")).toBe("jdbc:postgresql://db.example.com:5432/appdb?user=app_user&password=secret"); + }); + + it("adds driver-specific ssl properties", () => { + expect(buildConnectionUrlCopy(config({ ssl: true }), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb?ssl=true"); + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, ssl: true }), "jdbcUrl")).toBe("jdbc:mysql://db.example.com:3306/appdb?sslMode=REQUIRED"); + }); + + it("uses semicolon properties for SQL Server", () => { + const sqlserver = config({ db_type: "sqlserver", host: "sql.example.com", port: 1433, database: "appdb" }); + expect(buildConnectionUrlCopy(sqlserver, "jdbcUrl")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb"); + expect(buildConnectionUrlCopy(sqlserver, "jdbcUrlWithCredentials")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb;user=app_user;password=secret"); + expect(buildConnectionUrlCopy({ ...sqlserver, ssl: true }, "jdbcUrl")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb;encrypt=true"); + }); + + it("builds Oracle thin URLs for service names and SIDs", () => { + const oracle = config({ db_type: "oracle", host: "ora.example.com", port: 1521, database: "ORCLPDB1", username: "", password: "" }); + expect(buildConnectionUrlCopy(oracle, "jdbcUrl")).toBe("jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1"); + expect(buildConnectionUrlCopy({ ...oracle, oracle_connection_type: "sid" }, "jdbcUrl")).toBe("jdbc:oracle:thin:@ora.example.com:1521:ORCLPDB1"); + }); + + it("redacts explicit jdbc: strings for the plain item and keeps them verbatim for the credentials item", () => { + const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper;password=s3cret" }); + expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrl")).toBe("jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper;password=***"); + expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrlWithCredentials")).toBe(jdbcConfig.connection_string); + }); + + it("passes explicit jdbc: strings without secrets through unchanged", () => { + const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper" }); + expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrl")).toBe(jdbcConfig.connection_string); + expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrlWithCredentials")).toBe(jdbcConfig.connection_string); + }); + + it("selects dialects by driver profile", () => { + expect(buildConnectionUrlCopy(config({ db_type: "gaussdb", driver_profile: "gaussdb-m" }), "jdbcUrl")).toBe("jdbc:gaussdb://db.example.com:5432/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "gaussdb" }), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "gbase", port: 9088, driver_profile: "gbase8s" }), "jdbcUrl")).toBe("jdbc:gbasedbt-sqli://db.example.com:9088/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "gbase", port: 5258, driver_profile: "gbase8a" }), "jdbcUrl")).toBeNull(); + expect(buildConnectionUrlCopy(config({ db_type: "tdengine", port: 6041 }), "jdbcUrl")).toBe("jdbc:TAOS-RS://db.example.com:6041/appdb"); + expect(buildConnectionUrlCopy(config({ db_type: "tdengine", port: 6030 }), "jdbcUrl")).toBe("jdbc:TAOS://db.example.com:6030/appdb"); + }); + + it("hides the JDBC item when the dialect is unknown", () => { + expect(buildConnectionUrlCopy(config({ db_type: "mongodb", port: 27017 }), "jdbcUrl")).toBeNull(); + expect(connectionUrlCopyFormats(config({ db_type: "mongodb", port: 27017 }))).not.toContain("jdbcUrl"); + }); + + it("uses the database override for JDBC URLs too", () => { + expect(buildConnectionUrlCopy(config({}), "jdbcUrl", { database: "analytics" })).toBe("jdbc:postgresql://db.example.com:5432/analytics"); + }); +}); + +describe("buildConnectionUrlCopy host:port, DSN and psql", () => { + it("copies host:port", () => { + expect(buildConnectionUrlCopy(config({}), "hostPort")).toBe("db.example.com:5432"); + expect(buildConnectionUrlCopy(config({ port: 0 }), "hostPort")).toBe("db.example.com"); + }); + + it("builds a libpq key=value DSN without the password by default", () => { + expect(buildConnectionUrlCopy(config({}), "dsn")).toBe("host=db.example.com port=5432 user=app_user dbname=appdb"); + expect(buildConnectionUrlCopy(config({ ssl: true }), "dsn")).toBe("host=db.example.com port=5432 user=app_user dbname=appdb sslmode=require"); + }); + + it("embeds the password only in the explicit with-password DSN", () => { + expect(buildConnectionUrlCopy(config({}), "dsnWithPassword")).toBe("host=db.example.com port=5432 user=app_user password=secret dbname=appdb"); + expect(buildConnectionUrlCopy(config({ password: "has space" }), "dsnWithPassword")).toContain("password='has space'"); + expect(buildConnectionUrlCopy(config({ password: "" }), "dsnWithPassword")).toBe(buildConnectionUrlCopy(config({ password: "" }), "dsn")); + }); + + it("builds a psql command without embedding the password", () => { + expect(buildConnectionUrlCopy(config({}), "psqlCommand")).toBe("psql -h db.example.com -p 5432 -U app_user -d appdb"); + expect(buildConnectionUrlCopy(config({ database: undefined }), "psqlCommand", { database: "reporting" })).toBe("psql -h db.example.com -p 5432 -U app_user -d reporting"); + }); + + it("restricts DSN and psql to the PostgreSQL wire family", () => { + expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("dsn"); + expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("dsnWithPassword"); + expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("psqlCommand"); + expect(connectionUrlCopyFormats(config({ db_type: "redshift", port: 5439 }))).toContain("dsn"); + expect(connectionUrlCopyFormats(config({ db_type: "gaussdb", driver_profile: "gaussdb-m" }))).not.toContain("dsn"); + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "dsn")).toBeNull(); + expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "dsnWithPassword")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts new file mode 100644 index 0000000000..f95f8d3b63 --- /dev/null +++ b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts @@ -0,0 +1,494 @@ +import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect"; + +/** + * Builds copy-ready connection strings (standard URL / JDBC URL / libpq DSN / + * psql command) from a stored connection config. This is the inverse of + * `parseConnectionUrl()` in `connectionUrl.ts`: only formats that are actually + * usable in application code are produced, and unknown dialects are hidden + * from the menu instead of guessing a wrong URL. + * + * Password safety: the primary formats (`url`, `jdbcUrl`, `dsn`) never embed + * the stored password. Formats that do (`urlWithPassword`, + * `jdbcUrlWithCredentials`, `dsnWithPassword`) are only offered when a + * password is actually available, and the caller gates them behind an + * explicit confirmation. + */ + +export type ConnectionUrlCopyFormat = "url" | "urlWithPassword" | "jdbcUrl" | "jdbcUrlWithCredentials" | "hostPort" | "dsn" | "dsnWithPassword" | "psqlCommand"; + +/** Formats whose output embeds the stored password; callers should confirm before copying. */ +export const CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS: ReadonlySet = new Set(["urlWithPassword", "jdbcUrlWithCredentials", "dsnWithPassword"]); + +export type ConnectionUrlCopyConfig = Pick & Partial>; + +export interface ConnectionUrlCopyOptions { + /** Overrides `config.database` (e.g. the database tree node that was right-clicked). */ + database?: string; +} + +/** + * Types whose config shape does not map to a `scheme://user@host:port/db` URL + * (cloud resource paths, service registries, message queues, file paths). + */ +const URL_COPY_UNSUPPORTED_DB_TYPES = new Set(["bigquery", "spanner", "cloudflare-d1", "turso", "nacos", "mq", "mqtt", "hbase"]); + +/** + * Standard (non-JDBC) URL schemes keyed by db_type. Values intentionally match + * what real client libraries accept, not necessarily DBX's own display scheme. + */ +const STANDARD_URL_SCHEMES: Partial> = { + mysql: "mysql", + doris: "mysql", + starrocks: "mysql", + manticoresearch: "mysql", + goldendb: "mysql", + postgres: "postgresql", + redshift: "postgresql", + questdb: "postgresql", + kwdb: "postgresql", + yashandb: "postgresql", + highgo: "postgresql", + uxdb: "postgresql", + vastbase: "postgresql", + sundb: "postgresql", + gaussdb: "postgresql", + kingbase: "kingbase8", + sqlserver: "mssql", + oracle: "oracle", + clickhouse: "clickhouse", + mongodb: "mongodb", + redis: "redis", + dameng: "dm", + cassandra: "cassandra", + neo4j: "bolt", + etcd: "etcd", + zookeeper: "zookeeper", + oscar: "oscar", + xugu: "xugu", + iotdb: "iotdb", + iris: "iris", + trino: "trino", + databend: "databend", +}; + +/** Schemes that are plain HTTP(S) endpoints. */ +const HTTP_URL_DB_TYPES = new Set(["elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]); + +/** + * Generic `prefix://host:port/db` JDBC dialects. Dialects with a different URL + * shape (sqlserver, oracle, ...) are handled in `buildSpecialJdbcUrl()`. + */ +const JDBC_URL_PREFIXES: Partial> = { + mysql: "jdbc:mysql", + doris: "jdbc:mysql", + starrocks: "jdbc:mysql", + manticoresearch: "jdbc:mysql", + goldendb: "jdbc:mysql", + postgres: "jdbc:postgresql", + redshift: "jdbc:redshift", + questdb: "jdbc:postgresql", + gaussdb: "jdbc:postgresql", + opengauss: "jdbc:opengauss", + kingbase: "jdbc:kingbase8", + highgo: "jdbc:highgo", + clickhouse: "jdbc:clickhouse", + dameng: "jdbc:dm", + hive: "jdbc:hive2", + kyuubi: "jdbc:hive2", + argo: "jdbc:hive2", + impala: "jdbc:impala", + trino: "jdbc:trino", + prestosql: "jdbc:presto", + db2: "jdbc:db2", + vertica: "jdbc:vertica", + firebird: "jdbc:firebirdsql", + databend: "jdbc:databend", + cassandra: "jdbc:cassandra", + neo4j: "jdbc:neo4j:bolt", + ignite: "jdbc:ignite:thin", + ignite3: "jdbc:ignite:thin", + iotdb: "jdbc:iotdb", + iris: "jdbc:IRIS", + oscar: "jdbc:oscar", + xugu: "jdbc:xugu", +}; + +/** PostgreSQL wire-protocol families that accept libpq DSN and psql. */ +const POSTGRES_WIRE_DB_TYPES = new Set(["postgres", "redshift", "gaussdb", "questdb", "kwdb", "yashandb", "kingbase", "highgo", "uxdb", "vastbase", "sundb"]); + +/** PG-wire families whose JDBC driver accepts the `ssl`/`sslmode` properties. */ +const PG_JDBC_PREFIXES = new Set(["jdbc:postgresql", "jdbc:redshift", "jdbc:gaussdb", "jdbc:opengauss", "jdbc:kingbase8", "jdbc:highgo"]); + +function normalizedDriverProfile(config: ConnectionUrlCopyConfig): string { + return config.driver_profile?.trim().toLowerCase() ?? ""; +} + +function isLocalFileUrlCopyConnection(config: ConnectionUrlCopyConfig): boolean { + const dbType = config.db_type; + return dbType === "sqlite" || dbType === "duckdb" || dbType === "access" || (dbType === "h2" && Number(config.port) === 0); +} + +function explicitConnectionString(config: ConnectionUrlCopyConfig): string { + return config.connection_string?.trim() ?? ""; +} + +function looksLikeConnectionString(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value); +} + +export function connectionSupportsUrlCopy(config: ConnectionUrlCopyConfig | undefined): boolean { + if (!config) return false; + if (isLocalFileUrlCopyConnection(config)) return false; + if (URL_COPY_UNSUPPORTED_DB_TYPES.has(config.db_type)) return false; + return !!explicitConnectionString(config) || !!config.host?.trim(); +} + +function effectiveDatabase(config: ConnectionUrlCopyConfig, options?: ConnectionUrlCopyOptions): string { + return options?.database?.trim() || config.database?.trim() || ""; +} + +function queryHasParam(params: string, keys: string[]): boolean { + if (!params) return false; + const wanted = new Set(keys.map((key) => key.toLowerCase())); + return params + .split(/[&;]/) + .map((part) => part.split("=")[0]?.trim().toLowerCase()) + .some((key) => !!key && wanted.has(key)); +} + +function joinNonEmpty(separator: string, parts: (string | false | undefined | null)[]): string { + return parts.filter((part): part is string => typeof part === "string" && part.length > 0).join(separator); +} + +/** Brackets bare IPv6 literals; passes multi-host lists and full URLs through. */ +function formatHostForUrl(host: string): string { + if (host.includes("://") || host.includes(",") || host.startsWith("[")) return host; + return host.includes(":") ? `[${host}]` : host; +} + +function shouldAppendPort(config: ConnectionUrlCopyConfig): boolean { + const host = config.host?.trim() ?? ""; + return Number(config.port) > 0 && !host.includes(",") && !host.includes("://"); +} + +function buildUserInfo(config: ConnectionUrlCopyConfig, includePassword: boolean): string { + const user = config.username?.trim() ?? ""; + const password = config.password ?? ""; + if (includePassword) { + if (user && password) return `${encodeURIComponent(user)}:${encodeURIComponent(password)}@`; + if (user) return `${encodeURIComponent(user)}@`; + if (password) return `:${encodeURIComponent(password)}@`; + return ""; + } + return user ? `${encodeURIComponent(user)}@` : ""; +} + +/** Mirrors the sidebar tooltip redaction so shared strings never leak secrets. */ +function redactConnectionStringSecrets(value: string): string { + return value.replace(/(:\/\/[^/\s:@?#;]+):([^@\s/?#;]+)@/g, "$1:***@").replace(/([?&;](?:password|pwd|pass|token|secret|key)=)[^&;]*/gi, "$1***"); +} + +function standardUrlScheme(config: ConnectionUrlCopyConfig): string | null { + const profile = normalizedDriverProfile(config); + switch (config.db_type) { + case "mysql": + if (profile === "oceanbase") return "oceanbase"; + if (profile === "mariadb") return "mariadb"; + return "mysql"; + case "redis": + return config.ssl ? "rediss" : "redis"; + case "gaussdb": + return profile === GAUSSDB_M_JDBC_DRIVER_PROFILE ? "jdbc:gaussdb" : "postgresql"; + case "iris": + return profile === "cache" ? "cache" : "iris"; + default: { + if (HTTP_URL_DB_TYPES.has(config.db_type)) return config.db_type === "dynamodb" ? "https" : config.ssl ? "https" : "http"; + return STANDARD_URL_SCHEMES[config.db_type] ?? null; + } + } +} + +function standardUrlQuery(config: ConnectionUrlCopyConfig, scheme: string): string { + const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, ""); + const extras: string[] = []; + if (config.ssl) { + if ((scheme === "postgresql" || scheme === "kingbase8" || scheme === "jdbc:gaussdb") && !queryHasParam(raw, ["sslmode", "ssl"])) { + extras.push("sslmode=require"); + } else if (scheme === "mysql" && !queryHasParam(raw, ["ssl-mode", "sslmode", "require_ssl", "usessl"])) { + extras.push("ssl-mode=REQUIRED"); + } else if (scheme === "mongodb" && !queryHasParam(raw, ["tls", "ssl"])) { + extras.push("tls=true"); + } else if (scheme === "clickhouse" && !queryHasParam(raw, ["secure", "ssl"])) { + extras.push("secure=1"); + } else if (scheme === "cassandra" && !queryHasParam(raw, ["ssl"])) { + extras.push("ssl=true"); + } + } + return joinNonEmpty("&", [raw, ...extras]); +} + +function buildStandardUrl(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { includePassword: boolean }): string | null { + const explicit = explicitConnectionString(config); + const scheme = standardUrlScheme(config); + if (explicit && (!scheme || looksLikeConnectionString(explicit))) { + return options.includePassword ? explicit : redactConnectionStringSecrets(explicit); + } + if (!scheme) return null; + const host = config.host?.trim() ?? ""; + if (!host) return null; + + const database = effectiveDatabase(config, options); + const userInfo = buildUserInfo(config, options.includePassword); + const hostPart = formatHostForUrl(host); + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const path = database ? `/${encodeURIComponent(database)}` : ""; + const query = standardUrlQuery(config, scheme); + return `${scheme}://${userInfo}${hostPart}${portPart}${path}${query ? `?${query}` : ""}`; +} + +function jdbcPrefixFor(config: ConnectionUrlCopyConfig): string | null { + const profile = normalizedDriverProfile(config); + switch (config.db_type) { + case "mysql": + if (profile === "oceanbase") return "jdbc:oceanbase"; + if (profile === "mariadb") return "jdbc:mariadb"; + return "jdbc:mysql"; + case "gaussdb": + return profile === GAUSSDB_M_JDBC_DRIVER_PROFILE ? "jdbc:gaussdb" : "jdbc:postgresql"; + case "iris": + return profile === "cache" ? "jdbc:Cache" : "jdbc:IRIS"; + case "gbase": + return profile === "gbase8s" ? "jdbc:gbasedbt-sqli" : null; + case "tdengine": + return Number(config.port) === 6030 ? "jdbc:TAOS" : "jdbc:TAOS-RS"; + default: + return JDBC_URL_PREFIXES[config.db_type] ?? null; + } +} + +function jdbcCredentialParams(config: ConnectionUrlCopyConfig, withCredentials: boolean): string[] { + if (!withCredentials) return []; + const params: string[] = []; + const user = config.username?.trim() ?? ""; + if (user) params.push(`user=${encodeURIComponent(user)}`); + if (config.password) params.push(`password=${encodeURIComponent(config.password)}`); + return params; +} + +/** + * Dialects whose JDBC URL is not `prefix://host:port/db?query`. + * Returns `undefined` when the type is not a special dialect, `null` when the + * dialect is special but cannot be built from this config. + */ +function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string, withCredentials: boolean): string | null | undefined { + const host = config.host?.trim() ?? ""; + const rawParams = (config.url_params ?? "").trim(); + const credentials = jdbcCredentialParams(config, withCredentials); + + switch (config.db_type) { + case "sqlserver": { + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const props = joinNonEmpty(";", [database && `databaseName=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/&/g, ";"), config.ssl && !queryHasParam(rawParams, ["encrypt"]) && "encrypt=true"]); + return `jdbc:sqlserver://${formatHostForUrl(host)}${portPart}${props ? `;${props}` : ""}`; + } + case "oracle": { + const connectionType = config.oracle_connection_type ?? "service_name"; + if (connectionType === "tns") { + const explicit = explicitConnectionString(config); + return explicit ? explicit : null; + } + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const query = joinNonEmpty("&", [...credentials, rawParams.replace(/^[?&]+/, "")]); + if (connectionType === "sid") { + return `jdbc:oracle:thin:@${formatHostForUrl(host)}${portPart}:${database}${query ? `?${query}` : ""}`; + } + return `jdbc:oracle:thin:@//${formatHostForUrl(host)}${portPart}/${database}${query ? `?${query}` : ""}`; + } + case "saphana": { + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const query = joinNonEmpty("&", [database && `databaseName=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "")]); + return `jdbc:sap://${formatHostForUrl(host)}${portPart}/${query ? `?${query}` : ""}`; + } + case "teradata": { + const props = joinNonEmpty(",", [Number(config.port) > 0 && `DBS_PORT=${config.port}`, database && `DATABASE=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/[;&]/g, ",")]); + return `jdbc:teradata://${formatHostForUrl(host)}${props ? `/${props}` : ""}`; + } + case "exasol": { + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const props = joinNonEmpty(";", [database && `schema=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/&/g, ";")]); + return `jdbc:exa:${formatHostForUrl(host)}${portPart}${props ? `;${props}` : ""}`; + } + case "snowflake": { + if (!host) return null; + const query = joinNonEmpty("&", [database && `db=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "")]); + return `jdbc:snowflake://${formatHostForUrl(host)}/${query ? `?${query}` : ""}`; + } + case "informix": + case "gbase": { + const prefix = config.db_type === "informix" ? "jdbc:informix-sqli" : jdbcPrefixFor(config); + if (!prefix) return config.db_type === "gbase" ? null : undefined; + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + // Informix-style URLs append properties after a second colon: /db:INFORMIXSERVER=x;user=y + const props = joinNonEmpty(";", [rawParams.replace(/^[?:&]+/, ""), ...credentials.map((param) => decodeURIComponent(param))]); + return `${prefix}://${formatHostForUrl(host)}${portPart}/${database}${props ? `:${props}` : ""}`; + } + default: + return undefined; + } +} + +function jdbcQuery(config: ConnectionUrlCopyConfig, prefix: string, withCredentials: boolean): string { + const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, ""); + const parts = [...jdbcCredentialParams(config, withCredentials)]; + if (raw) parts.push(raw); + if (config.ssl) { + if (PG_JDBC_PREFIXES.has(prefix) && !queryHasParam(raw, ["ssl", "sslmode"])) { + parts.push("ssl=true"); + } else if ((prefix === "jdbc:mysql" || prefix === "jdbc:mariadb" || prefix === "jdbc:oceanbase") && !queryHasParam(raw, ["usessl", "sslmode", "requiressl"])) { + parts.push("sslMode=REQUIRED"); + } else if (prefix === "jdbc:clickhouse" && !queryHasParam(raw, ["ssl", "sslmode", "secure"])) { + parts.push("ssl=true"); + } + } + return joinNonEmpty("&", parts); +} + +function buildJdbcUrl(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { withCredentials: boolean }): string | null { + const explicit = explicitConnectionString(config); + if (explicit && /^jdbc:/i.test(explicit)) return options.withCredentials ? explicit : redactConnectionStringSecrets(explicit); + const host = config.host?.trim() ?? ""; + if (!host) return null; + + const database = effectiveDatabase(config, options); + const special = buildSpecialJdbcUrl(config, database, options.withCredentials); + if (special !== undefined) return special; + + const prefix = jdbcPrefixFor(config); + if (!prefix) return null; + const portPart = shouldAppendPort(config) ? `:${config.port}` : ""; + const path = database ? `/${encodeURIComponent(database)}` : ""; + const query = jdbcQuery(config, prefix, options.withCredentials); + return `${prefix}://${formatHostForUrl(host)}${portPart}${path}${query ? `?${query}` : ""}`; +} + +function buildHostPort(config: ConnectionUrlCopyConfig): string | null { + const host = config.host?.trim() ?? ""; + if (!host) return null; + if (host.includes(",") || host.includes("://")) return host; + const hostPart = formatHostForUrl(host); + return Number(config.port) > 0 ? `${hostPart}:${config.port}` : hostPart; +} + +function quoteDsnValue(value: string): string { + if (!/[\s'\\]/.test(value)) return value; + return `'${value.replace(/(['\\])/g, "\\$1")}'`; +} + +function isPostgresWireFamily(config: ConnectionUrlCopyConfig): boolean { + if (!POSTGRES_WIRE_DB_TYPES.has(config.db_type)) return false; + if (config.db_type === "gaussdb" && normalizedDriverProfile(config) === GAUSSDB_M_JDBC_DRIVER_PROFILE) return false; + const explicit = explicitConnectionString(config); + return !(explicit && /^jdbc:/i.test(explicit)); +} + +/** libpq-style `host=... port=... user=... [password=...] dbname=...` DSN. */ +function buildKeyvalueDsn(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { includePassword: boolean }): string | null { + const host = config.host?.trim() ?? ""; + if (!host) return null; + const parts = [`host=${quoteDsnValue(host)}`]; + if (shouldAppendPort(config)) parts.push(`port=${config.port}`); + const user = config.username?.trim() ?? ""; + if (user) parts.push(`user=${quoteDsnValue(user)}`); + if (options.includePassword && config.password) parts.push(`password=${quoteDsnValue(config.password)}`); + const database = effectiveDatabase(config, options); + if (database) parts.push(`dbname=${quoteDsnValue(database)}`); + const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, ""); + for (const param of raw.split("&")) { + const [key, ...rest] = param.split("="); + if (!key.trim()) continue; + parts.push(`${key.trim()}=${quoteDsnValue(decodeURIComponent(rest.join("=")))}`); + } + if (config.ssl && !queryHasParam(raw, ["sslmode"])) parts.push("sslmode=require"); + return parts.join(" "); +} + +function buildPsqlCommand(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions): string | null { + const host = config.host?.trim() ?? ""; + if (!host) return null; + const args = ["psql", "-h", host]; + if (shouldAppendPort(config)) args.push("-p", String(config.port)); + const user = config.username?.trim() ?? ""; + if (user) args.push("-U", user); + const database = effectiveDatabase(config, options); + if (database) args.push("-d", database); + return args.join(" "); +} + +/** + * Whether a password-inclusive copy would actually carry a secret: either a + * stored password or an explicit connection string with embedded credentials. + */ +function hasCopyableSecret(config: ConnectionUrlCopyConfig): boolean { + if (config.password) return true; + const explicit = explicitConnectionString(config); + return !!explicit && redactConnectionStringSecrets(explicit) !== explicit; +} + +/** + * Lists the copy formats available for a connection, deduplicating entries + * that would produce identical text (e.g. no stored password, or an explicit + * connection string that already is the JDBC URL). Password-inclusive formats + * are only offered when a secret is actually available, so the plain items + * are always safe to paste. + */ +export function connectionUrlCopyFormats(config: ConnectionUrlCopyConfig | undefined): ConnectionUrlCopyFormat[] { + if (!connectionSupportsUrlCopy(config)) return []; + const candidate = config as ConnectionUrlCopyConfig; + const formats: ConnectionUrlCopyFormat[] = []; + const hasSecret = hasCopyableSecret(candidate); + const url = buildStandardUrl(candidate, { includePassword: false }); + const urlWithPassword = buildStandardUrl(candidate, { includePassword: true }); + if (url) formats.push("url"); + if (urlWithPassword && hasSecret && urlWithPassword !== url) formats.push("urlWithPassword"); + const jdbcUrl = buildJdbcUrl(candidate, { withCredentials: false }); + const jdbcUrlWithCredentials = buildJdbcUrl(candidate, { withCredentials: true }); + if (jdbcUrl && jdbcUrl !== url) formats.push("jdbcUrl"); + // Without a secret the "with credentials" URL would only add `user=` (or + // nothing), which is misleading next to its label — hide it in that case. + if (jdbcUrlWithCredentials && hasSecret && jdbcUrlWithCredentials !== jdbcUrl && jdbcUrlWithCredentials !== urlWithPassword && jdbcUrlWithCredentials !== url) formats.push("jdbcUrlWithCredentials"); + if (buildHostPort(candidate)) formats.push("hostPort"); + if (isPostgresWireFamily(candidate)) { + const dsn = buildKeyvalueDsn(candidate, { includePassword: false }); + const dsnWithPassword = buildKeyvalueDsn(candidate, { includePassword: true }); + if (dsn) formats.push("dsn"); + if (dsnWithPassword && hasSecret && dsnWithPassword !== dsn) formats.push("dsnWithPassword"); + if (buildPsqlCommand(candidate, {})) formats.push("psqlCommand"); + } + return formats; +} + +export function buildConnectionUrlCopy(config: ConnectionUrlCopyConfig | undefined, format: ConnectionUrlCopyFormat, options?: ConnectionUrlCopyOptions): string | null { + if (!connectionSupportsUrlCopy(config)) return null; + const candidate = config as ConnectionUrlCopyConfig; + switch (format) { + case "url": + return buildStandardUrl(candidate, { ...options, includePassword: false }); + case "urlWithPassword": + return buildStandardUrl(candidate, { ...options, includePassword: true }); + case "jdbcUrl": + return buildJdbcUrl(candidate, { ...options, withCredentials: false }); + case "jdbcUrlWithCredentials": + return buildJdbcUrl(candidate, { ...options, withCredentials: true }); + case "hostPort": + return buildHostPort(candidate); + case "dsn": + return isPostgresWireFamily(candidate) ? buildKeyvalueDsn(candidate, { ...options, includePassword: false }) : null; + case "dsnWithPassword": + return isPostgresWireFamily(candidate) ? buildKeyvalueDsn(candidate, { ...options, includePassword: true }) : null; + case "psqlCommand": + return isPostgresWireFamily(candidate) ? buildPsqlCommand(candidate, options ?? {}) : null; + default: + return null; + } +}