From 491565fdda2f44d6625f03490a34ea7288047613 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 23 Sep 2026 15:42:02 +0800 Subject: [PATCH] fix(sqlserver): attribute login and catalog errors, keep sidebar search read-only --- .../src/components/sidebar/ConnectionTree.vue | 23 ++- .../ConnectionTree.collapsedSearch.spec.ts | 17 +- .../__tests__/sidebarSearchSkipParity.spec.ts | 71 +++++++ apps/desktop/src/i18n/locales/az.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + ...nnectionStore.sidebarSearchFailure.spec.ts | 189 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 82 +++++++- crates/dbx-drivers/src/db/sqlserver.rs | 185 +++++++++++++++-- 16 files changed, 542 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/i18n/__tests__/sidebarSearchSkipParity.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.sidebarSearchFailure.spec.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 545f6ed1ba..f9b5080d05 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -383,25 +383,28 @@ async function loadSidebarSearchTargets(query: string, preservesNodeSubtree?: (n function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearchTask[], refreshedNodeIds?: Set, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false, scheduledNodeIds?: Set) { const preservesSearchSubtree = ancestorPreservesSearchSubtree || (!!refreshedNodeIds && !!preservesNodeSubtree?.(node)); if (refreshedNodeIds && node.type === "connection" && node.connectionId) { - if (store.connectedIds.has(node.connectionId) && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { + const connectionIsConnected = store.connectedIds.has(node.connectionId); + if (connectionIsConnected && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { const connectionId = node.connectionId; scheduledNodeIds?.add(node.id); - tasks.push(() => store.loadConnectedConnectionRootForSidebarSearch(connectionId)); + tasks.push(() => store.loadConnectedConnectionRootForSidebarSearch(connectionId, { sidebarSearch: true })); } - if (node.connectionId !== store.activeConnectionId) return; + // 搜索不得替用户建连:只有连接中的连接(含当前激活的那个)才继续刷新子树。 + // 断开或连不上的连接直接跳过,后台搜索不会因此弹出凭据输入或写入整段连接错误。 + if (!connectionIsConnected || node.connectionId !== store.activeConnectionId) return; } if (refreshedNodeIds && isSimpleObjectSearchParent(node)) { if (!scheduledNodeIds || !scheduledNodeIds.has(node.id)) { scheduledNodeIds?.add(node.id); if (preservesSearchSubtree) { if (refreshedNodeIds.delete(node.id)) { - tasks.push(() => store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + tasks.push(() => store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })); } } else { const wasCollapsed = node.isExpanded !== true; refreshedNodeIds.add(node.id); if (wasCollapsed) searchExpansionState.markFiltered(node.id, true); - tasks.push(() => store.refreshTreeNode(node)); + tasks.push(() => store.refreshTreeNode(node, { sidebarSearch: true })); } } return; @@ -410,7 +413,7 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch scheduledNodeIds?.add(node.id); const wasCollapsed = node.isExpanded !== true; searchExpansionState.markFiltered(node.id, wasCollapsed); - tasks.push(() => store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + tasks.push(() => store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })); } if (refreshedNodeIds && node.children) { for (const child of node.children) { @@ -419,11 +422,11 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch scheduledNodeIds?.add(child.id); if (preservesSearchSubtree) { if (searchExpansionState.markUnfiltered(child.id)) { - tasks.push(() => store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + tasks.push(() => store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })); } } else { searchExpansionState.markFiltered(child.id, !child.isExpanded); - tasks.push(() => store.loadObjectGroupChildren(child, { force: true })); + tasks.push(() => store.loadObjectGroupChildren(child, { force: true, sidebarSearch: true })); } } } @@ -438,12 +441,12 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch } else if (!store.restoreFilteredObjectGroupChildren(node)) { // Nothing was captured because the group had not been loaded before the // search, so there is no previous list to put back. - tasks.push(() => store.loadObjectGroupChildren(node, { force: true })); + tasks.push(() => store.loadObjectGroupChildren(node, { force: true, sidebarSearch: true })); } } else if (simpleObjectParentTypes.has(node.type)) { const shouldCollapse = searchAutoExpandedNodeIds.has(node.id); tasks.push(async () => { - await store.refreshTreeNode(node); + await store.refreshTreeNode(node, { sidebarSearch: true }); if (shouldCollapse) node.isExpanded = false; }); } diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts index e26200e691..0464b4cd3e 100644 --- a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts @@ -12,7 +12,7 @@ describe("ConnectionTree global search loading", () => { // a searchable group instead of an empty child list. expect(source).toMatch(/isSidebarSearchContainer\(node\) && needsSidebarObjectGroupDiscovery\(node, searchableObjectGroupTypes\)/); expect(source).not.toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/); - expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })"); + expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery, sidebarSearch: true })"); expect(source).toContain("searchExpansionState.markFiltered(node.id, wasCollapsed)"); expect(source).toMatch(/if \(refreshedNodeIds && node\.children\) \{[\s\S]*?searchableObjectGroupTypes\.has\(child\.type\)/); }); @@ -29,7 +29,20 @@ describe("ConnectionTree global search loading", () => { // Clearing a remote search must put back the pages the user had loaded // through "load more"; only an uncaptured group falls back to a reload. expect(source).toContain("} else if (!store.restoreFilteredObjectGroupChildren(node)) {"); - expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true \}\)\);/); + expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true, sidebarSearch: true \}\)\);/); + }); + + it("never lets the global search connect a connection on the user's behalf", () => { + // Searching the sidebar is a background projection. Reconnecting here is what + // surfaced a raw SQL Server TLS/login error for a connection whose saved + // database no longer exists, so only already-connected connections may be + // refreshed and every search-driven load is tagged for the store. + expect(source).toContain("const connectionIsConnected = store.connectedIds.has(node.connectionId);"); + expect(source).toMatch(/if \(!connectionIsConnected \|\| node\.connectionId !== store\.activeConnectionId\) return;/); + // Search-driven loads are tagged so the store degrades a failed reconnect to a + // one-line skip hint instead of the raw driver error (see the store spec). + expect(source.match(/sidebarSearch: true/g)?.length ?? 0).toBeGreaterThanOrEqual(7); + expect(source).not.toMatch(/tasks\.push\(\(\) => store\.connect\(/); }); it("limits concurrent metadata loads without dropping a task", async () => { diff --git a/apps/desktop/src/i18n/__tests__/sidebarSearchSkipParity.spec.ts b/apps/desktop/src/i18n/__tests__/sidebarSearchSkipParity.spec.ts new file mode 100644 index 0000000000..6ce153e511 --- /dev/null +++ b/apps/desktop/src/i18n/__tests__/sidebarSearchSkipParity.spec.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { createI18n } from "vue-i18n"; +import { describe, expect, it } from "vitest"; +import az from "@/i18n/locales/az"; +import en from "@/i18n/locales/en"; +import es from "@/i18n/locales/es"; +import itLocale from "@/i18n/locales/it"; +import ja from "@/i18n/locales/ja"; +import ko from "@/i18n/locales/ko"; +import ptBR from "@/i18n/locales/pt-BR"; +import tr from "@/i18n/locales/tr"; +import zhCN from "@/i18n/locales/zh-CN"; +import zhTW from "@/i18n/locales/zh-TW"; + +// The sidebar-search skip hint is a single-line projection of a driver error +// (see connectionStore.recordSidebarSearchConnectionFailure). Every locale has +// to own the `{message}` placeholder, so this reads the locale SOURCES as text: +// non-English locales are `export default withEnglishFallback({ ... })`, and the +// import-time deep merge would hide a locale that silently dropped the key. +function localeSource(name: string): string { + return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8"); +} + +function declaredString(source: string, key: string): string | undefined { + return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1]; +} + +const localeNames = ["az", "en", "es", "it", "ja", "ko", "pt-BR", "tr", "zh-CN", "zh-TW"]; + +describe("sidebar search skip hint i18n parity", () => { + it("english declares the key with its message placeholder", () => { + const label = declaredString(localeSource("en"), "searchConnectionSkipped"); + expect(label).toContain("{message}"); + }); + + it.each(localeNames)("%s declares the key with the message placeholder in its source file", (name) => { + const label = declaredString(localeSource(name), "searchConnectionSkipped"); + expect(label, `${name}.ts must declare sidebar.searchConnectionSkipped`).toBeTruthy(); + expect(label).toContain("{message}"); + }); + + it.each(localeNames)("%s anchors the key inside the sidebar namespace", (name) => { + const source = localeSource(name); + expect(source.indexOf("searchConnectionSkipped:")).toBeGreaterThan(source.indexOf("sortConnectionsDescending:")); + expect(source.indexOf("searchConnectionSkipped:")).toBeLessThan(source.indexOf("searchScopeConnection:")); + }); +}); + +const localeModules: Array<[string, Record]> = [ + ["az", az], + ["en", en], + ["es", es], + ["it", itLocale], + ["ja", ja], + ["ko", ko], + ["pt-BR", ptBR], + ["tr", tr], + ["zh-CN", zhCN], + ["zh-TW", zhTW], +]; + +describe("sidebar search skip hint resolution", () => { + it.each(localeModules)("%s renders the driver detail inside the hint", (name, messages) => { + const sidebar = (messages.sidebar ?? {}) as typeof en.sidebar; + const i18n = createI18n({ legacy: false, locale: name, messages: { [name]: { sidebar } } }); + const hint = i18n.global.t("sidebar.searchConnectionSkipped", { message: "tls handshake eof" }); + expect(hint).not.toBe("sidebar.searchConnectionSkipped"); + expect(hint).toContain("tls handshake eof"); + i18n.dispose(); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 5db8f06718..da9103dc2d 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -431,6 +431,7 @@ export default withEnglishFallback({ sortConnectionsManual: "Əl ilə sıralama", sortConnectionsAscending: "Ad: A–Z", sortConnectionsDescending: "Ad: Z–A", + searchConnectionSkipped: "Axtarış bu əlaqəni atladı: {message}", searchScopeConnection: "Əlaqə", searchScopeDatabase: "Verilənlər bazası", searchScopeSchema: "Sxem", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e44a8c473b..8e9c5a317a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -638,6 +638,7 @@ export default { sortConnectionsManual: "Manual order", sortConnectionsAscending: "Name: A–Z", sortConnectionsDescending: "Name: Z–A", + searchConnectionSkipped: "Search skipped this connection: {message}", searchScopeConnection: "Connection", searchScopeDatabase: "Database", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index afac743e31..a9878b7c20 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -630,6 +630,7 @@ export default withEnglishFallback({ sortConnectionsManual: "Orden manual", sortConnectionsAscending: "Nombre: A–Z", sortConnectionsDescending: "Nombre: Z–A", + searchConnectionSkipped: "La búsqueda omitió esta conexión: {message}", searchScopeConnection: "Conexión", searchScopeDatabase: "Base de datos", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c0c6a8fc1a..99ecab40d2 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -628,6 +628,7 @@ export default withEnglishFallback({ sortConnectionsManual: "Ordine manuale", sortConnectionsAscending: "Nome: A–Z", sortConnectionsDescending: "Nome: Z–A", + searchConnectionSkipped: "La ricerca ha ignorato questa connessione: {message}", searchScopeConnection: "Connessione", searchScopeDatabase: "Database", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 949d239461..8a4221dd80 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -629,6 +629,7 @@ export default withEnglishFallback({ sortConnectionsManual: "手動順", sortConnectionsAscending: "名前: A–Z", sortConnectionsDescending: "名前: Z–A", + searchConnectionSkipped: "検索ではこの接続をスキップしました: {message}", searchScopeConnection: "接続", searchScopeDatabase: "データベース", searchScopeSchema: "スキーマ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 26164b54fa..ebc78697ed 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -636,6 +636,7 @@ export default withEnglishFallback({ sortConnectionsManual: "수동 순서", sortConnectionsAscending: "이름: 가나다순", sortConnectionsDescending: "이름: 다나가순", + searchConnectionSkipped: "검색에서 이 연결을 건너뛰었습니다: {message}", searchScopeConnection: "연결", searchScopeDatabase: "데이터베이스", searchScopeSchema: "스키마", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e8156cfcf2..08a30f8c69 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -629,6 +629,7 @@ export default withEnglishFallback({ sortConnectionsManual: "Ordem manual", sortConnectionsAscending: "Nome: A–Z", sortConnectionsDescending: "Nome: Z–A", + searchConnectionSkipped: "A busca ignorou esta conexão: {message}", searchScopeConnection: "Conexão", searchScopeDatabase: "Banco de dados", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index d7cddf6c0b..3b9c5887cd 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -433,6 +433,7 @@ export default withEnglishFallback({ sortConnectionsManual: "Elle sıralama", sortConnectionsAscending: "Ad: A–Z", sortConnectionsDescending: "Ad: Z–A", + searchConnectionSkipped: "Arama bu bağlantıyı atladı: {message}", searchScopeConnection: "Bağlantı", searchScopeDatabase: "Veritabanı", searchScopeSchema: "Şema", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e967a5ff9f..847aeb9ca9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -554,6 +554,7 @@ export default withEnglishFallback({ sortConnectionsManual: "手动排序", sortConnectionsAscending: "名称:A–Z", sortConnectionsDescending: "名称:Z–A", + searchConnectionSkipped: "搜索已跳过该连接:{message}", searchScopeConnection: "连接", searchScopeDatabase: "数据库", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c4fb75e0b7..6858836b11 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -632,6 +632,7 @@ export default withEnglishFallback({ sortConnectionsManual: "手動排序", sortConnectionsAscending: "名稱:A–Z", sortConnectionsDescending: "名稱:Z–A", + searchConnectionSkipped: "搜尋已略過此連線:{message}", searchScopeConnection: "連線", searchScopeDatabase: "資料庫", searchScopeSchema: "Schema", diff --git a/apps/desktop/src/stores/__tests__/connectionStore.sidebarSearchFailure.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.sidebarSearchFailure.spec.ts new file mode 100644 index 0000000000..6eabc2ea15 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.sidebarSearchFailure.spec.ts @@ -0,0 +1,189 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +// Verbatim shape of the WeChat report: an encrypted handshake that fails on a +// SQL Server 2014 instance, then a no-encryption fallback that reaches the login +// stage and is rejected because the saved database no longer exists (4060). +const LEGACY_SQLSERVER_CONNECT_ERROR = [ + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof", + "", + "This may be caused by an old SQL Server TLS/encryption configuration. If you are connecting to SQL Server 2008/2008 R2/2012 or another legacy instance, try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, when explicitly enabled, DBX can also fall back to the SQL Server legacy compatibility driver for TLS 1.0 encrypted transport. Only use this mode on trusted networks, VPNs, or SSH tunnels.", + "", + "Automatic native legacy fallback also failed: login-only encryption failed: SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof", + "no-encryption compatibility fallback failed: SQL Server connection failed: Token error: '无法打开登录所请求的数据库 \"cwxt2025\"。登录失败。' on server iZw1wl8nyooomlZ executing on line 1 (code: 4060, state: 1, class: 11)", +].join("\n"); + +function sqlServerConnection(overrides: Partial = {}): ConnectionConfig { + return { + id: "mssql-1", + name: "iZw1wl8nyooomlZ", + db_type: "sqlserver", + host: "10.0.0.9", + port: 1433, + username: "sa", + password: "secret", + database: "cwxt2025", + read_only: false, + ...overrides, + } as ConnectionConfig; +} + +function connectionTree(connection: ConnectionConfig): { connectionNode: TreeNode; tablesGroup: TreeNode } { + const tablesGroup: TreeNode = { + id: `${connection.id}:${connection.database}:__tables`, + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: connection.database, + isExpanded: true, + children: [], + }; + const connectionNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: `${connection.id}:${connection.database}`, + label: String(connection.database), + type: "database", + connectionId: connection.id, + database: connection.database, + isExpanded: true, + children: [tablesGroup], + }, + ], + }; + return { connectionNode, tablesGroup }; +} + +function mockBackend(connectDb: ReturnType, overrides: Record = {}) { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockRejectedValue(new Error("back end pool is gone")), + connectDb, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + ...overrides, + })); +} + +describe("connectionStore sidebar search connection failures", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + i18n.global.locale.value = "en"; + }); + + it("keeps only a one-line skip hint when a background search load cannot reconnect", async () => { + const connectDb = vi.fn().mockRejectedValue(new Error(LEGACY_SQLSERVER_CONNECT_ERROR)); + mockBackend(connectDb); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = sqlServerConnection(); + const { connectionNode, tablesGroup } = connectionTree(connection); + store.connections = [connection]; + store.treeNodes = [connectionNode]; + store.connectedIds = new Set([connection.id]); + store.activeConnectionId = connection.id; + + await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow(); + + expect(connectDb).toHaveBeenCalled(); + const recorded = store.connectionErrors[connection.id]; + // Search is a background projection: the node keeps one actionable line + // instead of the raw driver transcript with the misleading TLS hint. + expect(recorded).toBe(`Search skipped this connection: ${LEGACY_SQLSERVER_CONNECT_ERROR.split("\n")[0]}`); + expect(recorded).not.toContain("\n"); + expect(recorded).not.toContain("This may be caused by an old SQL Server TLS/encryption configuration"); + expect(store.connectedIds.has(connection.id)).toBe(false); + }, 15_000); + + it("still surfaces the full driver error for the same failure outside a search load", async () => { + const connectDb = vi.fn().mockRejectedValue(new Error(LEGACY_SQLSERVER_CONNECT_ERROR)); + mockBackend(connectDb); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = sqlServerConnection(); + const { connectionNode, tablesGroup } = connectionTree(connection); + store.connections = [connection]; + store.treeNodes = [connectionNode]; + store.connectedIds = new Set([connection.id]); + + await expect(store.loadObjectGroupChildren(tablesGroup, { force: true })).rejects.toThrow(); + + // An explicit action keeps the whole error so the user can read the real cause. + expect(store.connectionErrors[connection.id]).toBe(LEGACY_SQLSERVER_CONNECT_ERROR); + }, 15_000); + + // A live connection whose metadata query fails with a query-level error (SQL + // Server code 229, SELECT permission denied) must survive a background search: + // before the markConnectionLost gate, any non-cancelled search-driven failure + // silently disconnected it and cleared activeConnectionId. + const PERMISSION_DENIED_ERROR = 'SQL Server query failed: Token error: \'The SELECT permission was denied on the object "tables", database "cwxt2025", schema "dbo".\' on server iZw1wl8nyooomlZ (code: 229, state: 1, class: 16)'; + + const CONNECTION_LEVEL_ERROR = "SQL Server connection failed: An error occured during the attempt of performing I/O: connection reset by peer"; + + async function connectStoreWithLiveConnection(listTablesError: string) { + const connectDb = vi.fn(); + const checkConnectionHealth = vi.fn().mockResolvedValue(undefined); + const listTables = vi.fn().mockRejectedValue(new Error(listTablesError)); + mockBackend(connectDb, { checkConnectionHealth, listTables }); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = sqlServerConnection(); + const { connectionNode, tablesGroup } = connectionTree(connection); + store.connections = [connection]; + store.treeNodes = [connectionNode]; + store.connectedIds = new Set([connection.id]); + store.activeConnectionId = connection.id; + return { store, connection, tablesGroup, connectDb }; + } + + it("keeps a live connection connected when a search load fails with a query-level permission error", async () => { + const { store, connection, tablesGroup, connectDb } = await connectStoreWithLiveConnection(PERMISSION_DENIED_ERROR); + + await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow(PERMISSION_DENIED_ERROR); + + // A permission error is a query-level problem: the search records its one-line + // skip hint but must not disconnect the user's live connection. + expect(connectDb).not.toHaveBeenCalled(); + expect(store.connectionErrors[connection.id]).toBe(`Search skipped this connection: ${PERMISSION_DENIED_ERROR}`); + expect(store.connectedIds.has(connection.id)).toBe(true); + expect(store.activeConnectionId).toBe(connection.id); + }, 15_000); + + it("still marks a live connection lost when a search load fails with a connection-level error", async () => { + const { store, connection, tablesGroup } = await connectStoreWithLiveConnection(CONNECTION_LEVEL_ERROR); + + await expect(store.loadObjectGroupChildren(tablesGroup, { force: true, sidebarSearch: true })).rejects.toThrow(CONNECTION_LEVEL_ERROR); + + // A transport-level failure really did kill the connection: passive + // disconnect (and the one-line skip hint) still applies. + expect(store.connectionErrors[connection.id]).toBe(`Search skipped this connection: ${CONNECTION_LEVEL_ERROR}`); + expect(store.connectedIds.has(connection.id)).toBe(false); + expect(store.activeConnectionId).toBeNull(); + }, 15_000); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2dd8c2d867..8bd98baf4b 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -402,6 +402,10 @@ interface LoadTreeOptions { connectedOnly?: boolean; expectedSidebarSearchQuery?: string; searchFilter?: string; + // Set by the sidebar search walker: the load runs in the background, so a connection that + // cannot be reached must not leave the raw driver error on the node (see + // withSidebarSearchLoad). + sidebarSearch?: boolean; // Explicit actions can load the unfiltered backing group while the global search // continues to control presentation; normal watcher refreshes still reject mismatches. allowGlobalSearchMismatch?: boolean; @@ -1486,9 +1490,62 @@ export const useConnectionStore = defineStore("connection", () => { return false; } + /** + * 侧边栏搜索是后台投影:搜索为了读取元数据而被动重连失败时,不能把驱动的原始错误 + * (旧版 SQL Server 可能是一整段 TLS/加密提示)留在连接节点上,也不能每输入一个字就重试一次。 + * 搜索驱动的加载在这里登记,ensureConnected() 据此识别并降级这类失败。 + */ + const sidebarSearchLoadCounts = new Map(); + + function isSidebarSearchLoad(connectionId: string): boolean { + return (sidebarSearchLoadCounts.get(connectionId) ?? 0) > 0; + } + + async function withSidebarSearchLoad(connectionId: string | null | undefined, work: () => Promise): Promise { + const id = connectionId?.trim(); + if (!id) return work(); + sidebarSearchLoadCounts.set(id, (sidebarSearchLoadCounts.get(id) ?? 0) + 1); + try { + return await work(); + } finally { + const remaining = (sidebarSearchLoadCounts.get(id) ?? 1) - 1; + if (remaining > 0) sidebarSearchLoadCounts.set(id, remaining); + else sidebarSearchLoadCounts.delete(id); + } + } + + function sidebarSearchSkipMessage(error: unknown): string { + const firstLine = + connectionErrorMessage(error) + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + const detail = firstLine.length > 240 ? `${firstLine.slice(0, 240)}…` : firstLine; + return i18n.global.t("sidebar.searchConnectionSkipped", { message: detail }); + } + + /** + * 后台搜索重连失败:按被动断链处理(连接确实不可用了),但把驱动错误换成一行提示, + * 这样节点上只留一句“搜索已跳过该连接”,用户显式连接时仍能看到完整错误。 + * markLost=false:查询阶段连接仍然存活(如权限拒绝),只留一行跳过提示, + * 不替用户断开正在使用的连接。 + */ + function recordSidebarSearchConnectionFailure(connectionId: string, error: unknown, markLost = true) { + if (markLost) markConnectionLost(connectionId, error); + setConnectionError(connectionId, sidebarSearchSkipMessage(error)); + } + // Metadata loaders keep this internal: match connection-loss errors before recording generic errors. function recordMetadataLoadError(connectionId: string, error: unknown, load?: TreeNodeLoadHandle) { if (load && !load.isCurrent()) return; + // 搜索驱动的后台加载失败按“搜索跳过该连接”降级:搜索只是投影动作,不能把驱动原始错误 + // (旧版 SQL Server 会附带整段 TLS/加密提示)留在连接节点上。取消/被取代的尝试沿用原有清错处理。 + if (isSidebarSearchLoad(connectionId) && !isCancelledConnectionAttempt(error) && !isSupersededConnectionAttempt(error)) { + // 元数据查询失败未必意味着连接已死:只有连接级错误(与 recordConnectionLostError 同一判定) + // 才被动断链;权限拒绝等查询期错误保持连接,只降级为一行跳过提示。 + recordSidebarSearchConnectionFailure(connectionId, error, shouldMarkDisconnected(error)); + return; + } if (recordConnectionLostError(connectionId, error)) return; recordConnectionError(connectionId, error); } @@ -4696,7 +4753,10 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionError(connectionId); return; } - recordConnectionError(connectionId, e); + // 后台搜索触发的重连失败只留一行提示,避免把完整驱动错误(如旧版 SQL Server 的 TLS 提示) + // 记到连接节点上,也避免搜索每轮都重复报同一个错。 + if (isSidebarSearchLoad(connectionId) && !isSupersededConnectionAttempt(e)) recordSidebarSearchConnectionFailure(connectionId, e); + else recordConnectionError(connectionId, e); clearConnectionNodeLoading(connectionId); throw e; } finally { @@ -5116,7 +5176,10 @@ export const useConnectionStore = defineStore("connection", () => { ); } - async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) { + async function loadConnectedConnectionRootForSidebarSearch(connectionId: string, options?: { sidebarSearch?: boolean }): Promise { + if (options?.sidebarSearch) { + return withSidebarSearchLoad(connectionId, () => loadConnectedConnectionRootForSidebarSearch(connectionId, { ...options, sidebarSearch: false })); + } if (!connectedIds.value.has(connectionId)) return; const config = getConfig(connectionId); if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return; @@ -6260,7 +6323,10 @@ export const useConnectionStore = defineStore("connection", () => { ); } - async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) { + async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions): Promise { + if (options?.sidebarSearch) { + return withSidebarSearchLoad(node.connectionId, () => loadObjectGroupChildren(node, { ...options, sidebarSearch: false })); + } // Queued search/refresh tasks can outlive disconnect, which removes their nodes. if (!treeNodeInSidebarTree(node)) return; const packageOwnerId = packageMemberGroupOwnerId(node); @@ -7281,7 +7347,10 @@ export const useConnectionStore = defineStore("connection", () => { return ids; } - async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) { + async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions): Promise { + if (options?.sidebarSearch) { + return withSidebarSearchLoad(node.connectionId, () => loadTreeNodeChildren(node, { ...options, sidebarSearch: false })); + } if (node.type === "connection" && node.connectionId) { const config = getConfig(node.connectionId); if (config?.db_type === "redis") { @@ -7393,7 +7462,10 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) { + async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean; sidebarSearch?: boolean }): Promise { + if (options?.sidebarSearch) { + return withSidebarSearchLoad(node.connectionId, () => refreshTreeNode(node, { ...options, sidebarSearch: false })); + } invalidateCompletionCachesForNode(node); invalidateMetadataCachesForNode(node, options); if (objectTypesForGroupNode(node.type)) { diff --git a/crates/dbx-drivers/src/db/sqlserver.rs b/crates/dbx-drivers/src/db/sqlserver.rs index 3e573dbc40..6b03c241de 100644 --- a/crates/dbx-drivers/src/db/sqlserver.rs +++ b/crates/dbx-drivers/src/db/sqlserver.rs @@ -310,25 +310,86 @@ pub async fn connect_with_port_explicit( Err(encrypted_error) => { try_connect_legacy_sqlserver_encryption(host, port, port_explicit, user, pass, database, timeout) .await - .map_err(|plain_error| { - if is_sqlserver_tls_handshake_error(&encrypted_error) { - format!( - "{encrypted_error}\n\nThis may be caused by an old SQL Server TLS/encryption configuration. \ - If you are connecting to SQL Server 2008/2008 R2/2012 or another legacy instance, \ - try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, \ - when explicitly enabled, DBX can also fall back to the SQL Server legacy compatibility \ - driver for TLS 1.0 encrypted transport. Only use this mode on trusted networks, VPNs, \ - or SSH tunnels.\n\n\ - Automatic native legacy fallback also failed: {plain_error}" - ) - } else { - plain_error - } - }) + .map_err(|fallback_errors| sqlserver_connect_failure_message(&encrypted_error, &fallback_errors)) } } } +const SQLSERVER_LEGACY_TLS_HINT: &str = "This may be caused by an old SQL Server TLS/encryption configuration. \ + If you are connecting to SQL Server 2008/2008 R2/2012 or another legacy instance, \ + try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, \ + when explicitly enabled, DBX can also fall back to the SQL Server legacy compatibility \ + driver for TLS 1.0 encrypted transport. Only use this mode on trusted networks, VPNs, \ + or SSH tunnels."; + +/// Login/catalog error numbers reported by the server itself. They mean the TDS transport was +/// established, so the actionable fix is the login or the configured database, not TLS. +const SQLSERVER_LOGIN_OR_CATALOG_ERROR_NUMBERS: [u32; 8] = [4060, 18452, 18456, 18470, 18486, 18487, 18488, 18489]; + +fn sqlserver_legacy_fallback_summary(errors: &[(&'static str, String)]) -> String { + errors.iter().map(|(label, error)| format!("{label} failed: {error}")).collect::>().join("\n") +} + +/// Extracts the error number from a driver message such as +/// `... on server dbx executing on line 1 (code: 4060, state: 1, class: 11)`. +fn sqlserver_error_number(error: &str) -> Option { + let lower = error.to_ascii_lowercase(); + let rest = lower.split_once("(code:")?.1; + let digits = rest.trim_start().chars().take_while(char::is_ascii_digit).collect::(); + digits.parse().ok() +} + +fn is_sqlserver_login_or_catalog_error(error: &str) -> bool { + if sqlserver_error_number(error).is_some_and(|code| SQLSERVER_LOGIN_OR_CATALOG_ERROR_NUMBERS.contains(&code)) { + return true; + } + let lower = error.to_ascii_lowercase(); + [ + "login failed", + "cannot open database", + "not allowed to access", + "is not able to access the database", + "token error", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +/// Builds the final `connect` error for the encryption cascade. +/// +/// A fallback that reached the login/catalog stage proves the transport works without modern +/// encryption, so that error is the real cause and belongs on the first line: the TLS hint would +/// otherwise send users to their TLS configuration while the actual failure is a database that +/// cannot be opened (issue: SQL Server 2014 with a saved database that no longer exists). +fn sqlserver_connect_failure_message(encrypted_error: &str, fallback_errors: &[(&'static str, String)]) -> String { + let legacy_summary = sqlserver_legacy_fallback_summary(fallback_errors); + if let Some((label, error)) = + fallback_errors.iter().rev().find(|(_, error)| is_sqlserver_login_or_catalog_error(error)) + { + let mut message = error.clone(); + if is_sqlserver_tls_handshake_error(encrypted_error) { + message.push_str(&format!( + "\n\nDBX reached the server through the SQL Server legacy compatibility fallbacks ({label}), \ + so this failure is not caused by TLS/encryption settings. Check the database name, the login, \ + and its permissions for this connection; enable SQL Server legacy compatibility mode to skip \ + the failing encrypted handshake.\n\nInitial encrypted connection failed with: {encrypted_error}" + )); + } + return message; + } + if is_sqlserver_login_or_catalog_error(encrypted_error) { + return format!( + "{encrypted_error}\n\nThe SQL Server legacy compatibility fallbacks also failed:\n{legacy_summary}" + ); + } + if is_sqlserver_tls_handshake_error(encrypted_error) { + return format!( + "{encrypted_error}\n\n{SQLSERVER_LEGACY_TLS_HINT}\n\nAutomatic native legacy fallback also failed: {legacy_summary}" + ); + } + legacy_summary +} + async fn try_connect_legacy_sqlserver_encryption( host: &str, port: u16, @@ -337,16 +398,16 @@ async fn try_connect_legacy_sqlserver_encryption( pass: &str, database: Option<&str>, timeout: Duration, -) -> Result { +) -> Result> { let mut errors = Vec::new(); for (label, encryption) in SQLSERVER_LEGACY_ENCRYPTION_FALLBACKS { match try_connect(host, port, port_explicit, user, pass, database, encryption, timeout).await { Ok(client) => return Ok(client), - Err(error) => errors.push(format!("{label} failed: {error}")), + Err(error) => errors.push((label, error)), } } - Err(errors.join("\n")) + Err(errors) } pub fn sqlserver_native_encryption_disabled(url_params: Option<&str>) -> bool { @@ -4378,6 +4439,94 @@ mod tests { assert!(!super::is_sqlserver_tls_handshake_error("SQL Server connection failed: Login failed for user")); } + #[test] + fn sqlserver_login_error_detection_covers_server_codes_and_messages() { + assert!(super::is_sqlserver_login_or_catalog_error( + "SQL Server connection failed: Token error: 'Cannot open database \"cwxt2025\" requested by the login. \ + The login failed.' on server dbx executing on line 1 (code: 4060, state: 1, class: 11)" + )); + assert!(super::is_sqlserver_login_or_catalog_error( + "SQL Server connection failed: Login failed for user 'sa'. (code: 18456, state: 1, class: 14)" + )); + assert!(super::is_sqlserver_login_or_catalog_error("Login failed for user 'readonly'.")); + // Localized server text (zh-CN) keeps no english needle, so "token error" has to carry it. + assert!(super::is_sqlserver_login_or_catalog_error( + "SQL Server connection failed: Token error: '\u{65e0}\u{6cd5}\u{6253}\u{5f00}\u{767b}\u{5f55}\u{6240}\u{8bf7}\u{6c42}\u{7684}\u{6570}\u{636e}\u{5e93} \"cwxt2025\"\u{3002}\u{767b}\u{5f55}\u{5931}\u{8d25}\u{3002}'" + )); + assert!(!super::is_sqlserver_login_or_catalog_error( + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof" + )); + assert!(!super::is_sqlserver_login_or_catalog_error("SQL Server connection timed out (30s)")); + } + + #[test] + fn sqlserver_login_failure_outranks_the_legacy_tls_hint() { + // The reported case: the encrypted handshake fails, but the no-encryption fallback reaches + // the login stage and the server rejects the configured database (4060). The catalog error is + // the actionable one and must not be buried under the TLS hint. The same instance reports the + // server text in its own language (zh-CN here), so both spellings have to win. + let encrypted_error = + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof"; + let catalog_failures = [ + "SQL Server connection failed: Token error: 'Cannot open database \"cwxt2025\" requested by the login. \ + The login failed.' on server dbx executing on line 1 (code: 4060, state: 1, class: 11)", + "SQL Server connection failed: Token error: '\u{65e0}\u{6cd5}\u{6253}\u{5f00}\u{767b}\u{5f55}\u{6240}\u{8bf7}\u{6c42}\u{7684}\u{6570}\u{636e}\u{5e93} \"cwxt2025\"\u{3002}\u{767b}\u{5f55}\u{5931}\u{8d25}\u{3002}' on server iZw1wl8nyooomlZ executing on line 1 (code: 4060, state: 1, class: 11)", + ]; + + for catalog_failure in catalog_failures { + let fallback_errors = vec![ + ("login-only encryption", encrypted_error.to_string()), + ("no-encryption compatibility fallback", catalog_failure.to_string()), + ]; + + let message = super::sqlserver_connect_failure_message(encrypted_error, &fallback_errors); + + assert!(message.starts_with(catalog_failure), "{message}"); + assert!(message.contains("not caused by TLS/encryption settings")); + assert!(message.contains("Initial encrypted connection failed with:")); + assert!(!message.contains("This may be caused by an old SQL Server TLS/encryption configuration")); + } + } + + #[test] + fn sqlserver_initial_login_failure_keeps_its_priority_over_fallback_transport_errors() { + let login_error = + "SQL Server connection failed: Login failed for user 'sa'. (code: 18456, state: 1, class: 14)"; + let fallback_errors = vec![ + ( + "login-only encryption", + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof" + .to_string(), + ), + ( + "no-encryption compatibility fallback", + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof" + .to_string(), + ), + ]; + + let message = super::sqlserver_connect_failure_message(login_error, &fallback_errors); + + assert!(message.starts_with("SQL Server connection failed: Login failed for user 'sa'.")); + assert!(message.contains("legacy compatibility fallbacks also failed")); + } + + #[test] + fn sqlserver_all_tls_failures_keep_the_legacy_compatibility_hint() { + let encrypted_error = + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof"; + let fallback_errors = vec![ + ("login-only encryption", encrypted_error.to_string()), + ("no-encryption compatibility fallback", encrypted_error.to_string()), + ]; + + let message = super::sqlserver_connect_failure_message(encrypted_error, &fallback_errors); + + assert!(message.starts_with(encrypted_error)); + assert!(message.contains("This may be caused by an old SQL Server TLS/encryption configuration")); + assert!(message.contains("Automatic native legacy fallback also failed:")); + } + #[test] fn sqlserver_module_definitions_require_simple_query_batch() { assert!(requires_simple_query_batch("SET SHOWPLAN_XML ON;"));