From f5c25510dc538cf3755a80574fa9d3e81f40a49e Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 28 Jul 2026 23:59:34 +0800 Subject: [PATCH] fix(sql): prevent oversized SQL editor loads --- apps/desktop/src/App.vue | 25 ++-- .../src/components/layout/SqlFilePanel.vue | 8 +- .../src/components/layout/SqlLibraryPanel.vue | 3 +- apps/desktop/src/composables/useFileDrop.ts | 17 ++- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/__tests__/sql/sqlFileOpen.spec.ts | 39 ++++++- .../__tests__/externalSqlFileApi.spec.ts | 34 ++++++ apps/desktop/src/lib/backend/tauri.ts | 7 +- apps/desktop/src/lib/sql/sqlFileOpen.ts | 57 +++++++++ src-tauri/src/commands/external_sql.rs | 110 ++++++++++++++++-- 16 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e3a1fa5ee..a01e7d641 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -50,7 +50,7 @@ import { uuid } from "@/lib/common/utils"; import { isMacOS, isWindows } from "@/lib/backend/platform"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; -import { sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; +import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { @@ -1084,21 +1084,19 @@ async function openSqlFile() { const input = document.createElement("input"); input.type = "file"; input.accept = ".sql"; - input.onchange = () => { + input.onchange = async () => { const file = input.files?.[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") { - queryStore.updateSql(tab.id, reader.result); - } - }; - reader.readAsText(file); + try { + queryStore.updateSql(tab.id, await readBrowserSqlFile(file)); + } catch (e: any) { + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + } }; input.click(); } } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } } @@ -1131,7 +1129,7 @@ async function openSqlFilePath(path: string) { const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : ""); queryStore.openExternalSqlFile(connectionId, database, path, content); } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } } @@ -1650,7 +1648,10 @@ async function handleQuickOpenSelect(item: any) { const database = connection ? resolveDefaultDatabase(connection, []) : ""; queryStore.openExternalSqlFile(connectionId, database, item.filePath, content); } catch (e: any) { - toast(e?.message || String(e), 5000); + toast( + externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), + 5000, + ); } return; } diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index d93457b44..c06213e29 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -11,6 +11,7 @@ import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; import * as api from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api"; import { getSqlFileFolderPaths, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders"; @@ -211,7 +212,12 @@ async function openFile(path: string) { const database = connection ? resolveDefaultDatabase(connection, []) : ""; queryStore.openExternalSqlFile(connectionId, database, path, content); } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + if (isExternalSqlFileTooLargeError(e)) { + executeFile(path); + toast(t("sqlFile.largeFileExecutionOpened", { size: formatSqlFileSize(e.sizeBytes) }), 6000); + return; + } + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } } diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 5ac614b1d..09c785449 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -10,6 +10,7 @@ import LightTooltip from "@/components/ui/LightTooltip.vue"; import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import * as api from "@/lib/backend/api"; +import { externalSqlFileOpenErrorMessage } from "@/lib/sql/sqlFileOpen"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -265,7 +266,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { toast(t("sqlLibrary.imported", { count: sqlPaths.length }), 2500); } catch (e: any) { - toast(t("sqlLibrary.importFailed", { message: e?.message || String(e) }), 5000); + toast(t("sqlLibrary.importFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } } diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 67172a05a..952b8fa38 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -7,6 +7,7 @@ import { useToast } from "@/composables/useToast"; import * as api from "@/lib/backend/api"; import type { ConnectionConfig } from "@/types/database"; import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection"; +import { externalSqlFileOpenErrorMessage, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen"; function isSqlFilePath(path: string): boolean { return /\.sql$/i.test(path); @@ -72,7 +73,7 @@ export function useFileDrop() { const content = await api.readExternalSqlFile(path); await openDroppedSqlFile(name, content, path); } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } continue; } @@ -108,15 +109,11 @@ export function useFileDrop() { for (let i = 0; i < files.length; i++) { const file = files[i]; if (!isSqlFilePath(file.name)) continue; - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") { - openDroppedSqlFile(file.name, reader.result).catch((e: any) => { - toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); - }); - } - }; - reader.readAsText(file); + void readBrowserSqlFile(file) + .then((content) => openDroppedSqlFile(file.name, content)) + .catch((e: any) => { + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + }); } }); document.addEventListener("dragover", (event: DragEvent) => { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index f46872220..8cce9a67b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3665,6 +3665,8 @@ export default { browse: "Browse", previewingLines: "Previewing {count} lines", previewingFirstLines: "Previewing first {count} lines", + tooLargeForEditor: "This SQL file is {size}, exceeding the editor limit of {limit}. Use Execute SQL File to process it as a stream.", + largeFileExecutionOpened: "This {size} SQL file is too large for the editor. Execute SQL File has been opened for streaming.", target: "Target", connection: "Connection", selectConnection: "Select connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 253bb8b10..fcb0bd8ba 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3471,6 +3471,8 @@ export default withEnglishFallback({ browse: "Examinar", previewingLines: "Vista previa de {count} líneas", previewingFirstLines: "Vista previa de las primeras {count} líneas", + tooLargeForEditor: "Este archivo SQL ocupa {size} y supera el límite de {limit} del editor. Use Ejecutar archivo SQL para procesarlo como flujo.", + largeFileExecutionOpened: "El archivo SQL de {size} es demasiado grande para el editor. Se abrió Ejecutar archivo SQL para procesarlo como flujo.", target: "Destino", connection: "Conexión", selectConnection: "Seleccionar conexión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 89d83cb63..3d17d5955 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3469,6 +3469,8 @@ export default withEnglishFallback({ browse: "Sfoglia", previewingLines: "Anteprima di {count} righe", previewingFirstLines: "Anteprima delle prime {count} righe", + tooLargeForEditor: "Questo file SQL è di {size} e supera il limite di {limit} dell'editor. Usa Esegui file SQL per elaborarlo in streaming.", + largeFileExecutionOpened: "Il file SQL di {size} è troppo grande per l'editor. È stato aperto Esegui file SQL per l'elaborazione in streaming.", target: "Destinazione", connection: "Connessione", selectConnection: "Seleziona connessione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3ca515077..37765e4c3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3471,6 +3471,8 @@ export default withEnglishFallback({ browse: "参照", previewingLines: "{count}行をプレビュー中", previewingFirstLines: "先頭{count}行をプレビュー中", + tooLargeForEditor: "このSQLファイルは{size}あり、エディターの上限{limit}を超えています。ストリーム処理するには「SQLファイルを実行」を使用してください。", + largeFileExecutionOpened: "{size}のSQLファイルはエディターには大きすぎるため、ストリーム処理用の「SQLファイルを実行」を開きました。", target: "対象", connection: "接続", selectConnection: "接続を選択", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6ab27b4d7..f462696b5 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3471,6 +3471,8 @@ export default withEnglishFallback({ browse: "Procurar", previewingLines: "Pré-visualizando {count} linhas", previewingFirstLines: "Pré-visualizando as primeiras {count} linhas", + tooLargeForEditor: "Este arquivo SQL tem {size} e excede o limite de {limit} do editor. Use Executar Arquivo SQL para processá-lo por streaming.", + largeFileExecutionOpened: "O arquivo SQL de {size} é grande demais para o editor. Executar Arquivo SQL foi aberto para processamento por streaming.", target: "Destino", connection: "Conexão", selectConnection: "Selecionar conexão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 135b52854..4a1e91b39 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3665,6 +3665,8 @@ export default withEnglishFallback({ browse: "浏览", previewingLines: "预览 {count} 行", previewingFirstLines: "预览前 {count} 行", + tooLargeForEditor: "SQL 文件大小为 {size},超过编辑器 {limit} 的限制。请使用“执行 SQL 文件”以流式方式处理。", + largeFileExecutionOpened: "该 SQL 文件大小为 {size},无法载入编辑器,已打开“执行 SQL 文件”进行流式处理。", target: "目标", connection: "连接", selectConnection: "选择连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f4849dda0..92eb13646 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3142,6 +3142,8 @@ export default withEnglishFallback({ browse: "瀏覽", previewingLines: "預覽 {count} 行", previewingFirstLines: "預覽前 {count} 行", + tooLargeForEditor: "SQL 檔案大小為 {size},超過編輯器 {limit} 的限制。請使用「執行 SQL 檔案」以串流方式處理。", + largeFileExecutionOpened: "此 SQL 檔案大小為 {size},無法載入編輯器,已開啟「執行 SQL 檔案」進行串流處理。", target: "目標", connection: "連線", selectConnection: "選擇連線", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts index 80e8822c2..92a5bf028 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts @@ -1,5 +1,7 @@ +// @vitest-environment happy-dom + import { describe, expect, it } from "vitest"; -import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; +import { ExternalSqlFileTooLargeError, externalSqlFileDisplayTitles, externalSqlFileOpenErrorMessage, formatSqlFileSize, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, normalizeExternalSqlPath, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen"; describe("external SQL file paths", () => { it("normalizes Windows separators for identity checks", () => { @@ -14,3 +16,38 @@ describe("external SQL file paths", () => { expect(externalSqlFileDisplayTitles(["/one/sql/create.sql", "/two/sql/create.sql"])).toEqual(["one/sql/create.sql", "two/sql/create.sql"]); }); }); + +describe("external SQL file editor limit", () => { + it("accepts the exact browser editor limit", async () => { + const file = new Blob(["select 1;"]); + Object.defineProperty(file, "size", { value: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES }); + + await expect(readBrowserSqlFile(file)).resolves.toBe("select 1;"); + }); + + it("rejects browser files above the editor limit before reading", async () => { + const file = new Blob(["select 1;"]); + Object.defineProperty(file, "size", { value: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1 }); + + await expect(readBrowserSqlFile(file)).rejects.toMatchObject({ + name: "ExternalSqlFileTooLargeError", + sizeBytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1, + maxSizeBytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + }); + }); + + it("formats large file sizes", () => { + expect(formatSqlFileSize(64 * 1024 * 1024)).toBe("64.0 MB"); + expect(formatSqlFileSize(50 * 1024 * 1024 * 1024)).toBe("50.0 GB"); + }); + + it("builds a localized actionable message for oversized files", () => { + const message = externalSqlFileOpenErrorMessage(new ExternalSqlFileTooLargeError(50 * 1024 ** 3, 64 * 1024 ** 2), (_key, params) => `${params.size}/${params.limit}`); + + expect(message).toBe("50.0 GB/64.0 MB"); + }); + + it("preserves ordinary backend error messages", () => { + expect(externalSqlFileOpenErrorMessage(new Error("permission denied"), () => "unused")).toBe("permission denied"); + }); +}); diff --git a/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts new file mode 100644 index 000000000..4771732c4 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +import { readExternalSqlFile } from "@/lib/backend/tauri"; + +describe("external SQL file API", () => { + beforeEach(() => { + mocks.invoke.mockReset(); + }); + + it("returns editor content from the structured backend response", async () => { + mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;" }); + + await expect(readExternalSqlFile("/tmp/demo.sql")).resolves.toBe("select 1;"); + expect(mocks.invoke).toHaveBeenCalledWith("read_external_sql_file", { path: "/tmp/demo.sql" }); + }); + + it("maps oversized responses to a typed frontend error", async () => { + mocks.invoke.mockResolvedValue({ kind: "tooLarge", sizeBytes: 50 * 1024 ** 3, maxSizeBytes: 64 * 1024 ** 2 }); + + const error = await readExternalSqlFile("/tmp/backup.sql").catch((reason) => reason); + + expect(error).toBeInstanceOf(ExternalSqlFileTooLargeError); + expect(error).toMatchObject({ sizeBytes: 50 * 1024 ** 3, maxSizeBytes: 64 * 1024 ** 2 }); + }); +}); diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 1b0e0052d..404fe695a 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand"; +import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, ConnectionTestResult, @@ -670,7 +671,11 @@ export async function pendingOpenConnectionLinks(): Promise { } export async function readExternalSqlFile(path: string): Promise { - return invoke("read_external_sql_file", { path }); + const result = await invoke<{ kind: "content"; content: string } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path }); + if (result.kind === "tooLarge") { + throw new ExternalSqlFileTooLargeError(result.sizeBytes, result.maxSizeBytes); + } + return result.content; } export async function writeExternalSqlFile(path: string, content: string): Promise { diff --git a/apps/desktop/src/lib/sql/sqlFileOpen.ts b/apps/desktop/src/lib/sql/sqlFileOpen.ts index 8926e3017..3c28e2448 100644 --- a/apps/desktop/src/lib/sql/sqlFileOpen.ts +++ b/apps/desktop/src/lib/sql/sqlFileOpen.ts @@ -46,3 +46,60 @@ export function externalSqlFileDisplayTitles(paths: string[]): string[] { export function externalSqlFilePaths(paths: string[]): string[] { return paths.filter(isSqlFilePath); } + +export const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES = 64 * 1024 * 1024; + +export class ExternalSqlFileTooLargeError extends Error { + constructor( + readonly sizeBytes: number, + readonly maxSizeBytes: number, + ) { + super("SQL file is too large to open in the editor"); + this.name = "ExternalSqlFileTooLargeError"; + } +} + +export function isExternalSqlFileTooLargeError(error: unknown): error is ExternalSqlFileTooLargeError { + return error instanceof ExternalSqlFileTooLargeError; +} + +export function readBrowserSqlFile(file: Blob): Promise { + if (file.size > MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES) { + return Promise.reject(new ExternalSqlFileTooLargeError(file.size, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES)); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result); + else reject(new Error("Failed to read SQL file as text")); + }; + reader.onerror = () => reject(reader.error ?? new Error("Failed to read SQL file")); + reader.onabort = () => reject(new Error("SQL file read was cancelled")); + reader.readAsText(file); + }); +} + +export function formatSqlFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${unit}`; +} + +export function externalSqlFileOpenErrorMessage(error: unknown, translate: (key: string, params: { size: string; limit: string }) => string): string { + if (isExternalSqlFileTooLargeError(error)) { + return translate("sqlFile.tooLargeForEditor", { + size: formatSqlFileSize(error.sizeBytes), + limit: formatSqlFileSize(error.maxSizeBytes), + }); + } + if (error instanceof Error) return error.message; + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message; + return String(error); +} diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index 523ba8392..822a67fd1 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -2,6 +2,21 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use dbx_core::sql::decode_sql_file_bytes; +use serde::Serialize; +use tokio::io::AsyncReadExt; + +const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 64 * 1024 * 1024; + +fn exceeds_external_sql_editor_limit(size_bytes: u64) -> bool { + size_bytes > MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ExternalSqlFileReadResult { + Content { content: String }, + TooLarge { size_bytes: u64, max_size_bytes: u64 }, +} use tauri_plugin_dialog::DialogExt; #[tauri::command] @@ -13,7 +28,7 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> } #[tauri::command] -pub async fn read_external_sql_file(path: String) -> Result { +pub async fn read_external_sql_file(path: String) -> Result { read_external_sql_file_content_async(PathBuf::from(path)).await } @@ -90,20 +105,45 @@ pub fn is_sql_file_path(path: &Path) -> bool { } #[cfg(test)] -fn read_external_sql_file_content(path: &Path) -> Result { +fn read_external_sql_file_content(path: &Path) -> Result { if !is_sql_file_path(path) { return Err("Only .sql files can be opened this way".to_string()); } + let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to inspect SQL file: {e}"))?; + if exceeds_external_sql_editor_limit(metadata.len()) { + return Ok(ExternalSqlFileReadResult::TooLarge { + size_bytes: metadata.len(), + max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + }); + } let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?; - decode_sql_file_bytes(&bytes) + decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content }) } -async fn read_external_sql_file_content_async(path: PathBuf) -> Result { +async fn read_external_sql_file_content_async(path: PathBuf) -> Result { if !is_sql_file_path(&path) { return Err("Only .sql files can be opened this way".to_string()); } - let bytes = tokio::fs::read(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?; - decode_sql_file_bytes(&bytes) + let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?; + let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?; + if exceeds_external_sql_editor_limit(metadata.len()) { + return Ok(ExternalSqlFileReadResult::TooLarge { + size_bytes: metadata.len(), + max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + }); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .await + .map_err(|e| format!("Failed to read SQL file: {e}"))?; + if exceeds_external_sql_editor_limit(bytes.len() as u64) { + return Ok(ExternalSqlFileReadResult::TooLarge { + size_bytes: bytes.len() as u64, + max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + }); + } + decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content }) } #[cfg(test)] @@ -186,7 +226,7 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), "select 1;"); + assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select 1;".to_string() }); } #[test] @@ -197,7 +237,7 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - assert_eq!(result.unwrap(), "select '中文';"); + assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select '中文';".to_string() }); } #[test] @@ -211,6 +251,60 @@ mod tests { assert!(result.unwrap_err().contains(".sql")); } + #[test] + fn external_sql_editor_limit_is_inclusive() { + assert!(!exceeds_external_sql_editor_limit(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES)); + assert!(exceeds_external_sql_editor_limit(MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1)); + } + + #[test] + fn rejects_oversized_external_sql_before_reading_content() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + let file = std::fs::File::create(&path).unwrap(); + let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1; + file.set_len(file_size).unwrap(); + + let result = read_external_sql_file_content(&path); + + let _ = std::fs::remove_file(&path); + assert_eq!( + result.unwrap(), + ExternalSqlFileReadResult::TooLarge { + size_bytes: file_size, + max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + } + ); + } + + #[tokio::test] + async fn rejects_oversized_external_sql_in_async_command_path() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + let file = std::fs::File::create(&path).unwrap(); + let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1; + file.set_len(file_size).unwrap(); + + let result = read_external_sql_file_content_async(path.clone()).await; + + let _ = std::fs::remove_file(&path); + assert_eq!( + result.unwrap(), + ExternalSqlFileReadResult::TooLarge { + size_bytes: file_size, + max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, + } + ); + } + + #[test] + fn serializes_external_sql_file_limit_for_frontend() { + let result = ExternalSqlFileReadResult::TooLarge { size_bytes: 100, max_size_bytes: 64 }; + + assert_eq!( + serde_json::to_value(result).unwrap(), + serde_json::json!({ "kind": "tooLarge", "sizeBytes": 100, "maxSizeBytes": 64 }) + ); + } + #[test] fn writes_external_sql_file_content() { let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));