From 242470eb0902f66222cfe3a7439b884dd3ba2e60 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 9 Apr 2026 20:33:46 +0530 Subject: [PATCH 1/3] refactor(sqlite): enhance SQLite path validation and normalization --- bridge/src/connectors/sqlite.ts | 72 ++++++++------------- bridge/src/services/connectionBuilder.ts | 43 ++----------- bridge/src/services/databaseService.ts | 22 +++++++ bridge/src/services/dbStore.ts | 79 ++++++++++++++++++++---- bridge/src/utils/sqlitePath.ts | 40 ++++++++++++ 5 files changed, 163 insertions(+), 93 deletions(-) create mode 100644 bridge/src/utils/sqlitePath.ts diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index 953c0e2..0815fb0 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -6,6 +6,7 @@ import fs from "fs"; import os from "os"; import path from "path"; import { ensureDir, getMigrationsDir } from "../utils/config"; +import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath"; import { CacheEntry, CACHE_TTL, @@ -257,37 +258,35 @@ export const sqliteCache = new SQLiteCacheManager(); let cachedNativeBindingPath: string | null | undefined = undefined; -function normalizeSQLitePath(rawPath: string): string { - const trimmed = rawPath.trim(); - if (!trimmed) { - return trimmed; - } +function validateSQLitePath( + rawPath: string, + options: { requireExistingFile?: boolean } = {} +): string { + const dbPath = normalizeSQLitePath(rawPath); - if (/^\/[A-Za-z]:\//.test(trimmed)) { - return trimmed.slice(1); + if (!dbPath || !dbPath.trim()) { + throw new Error(`Invalid SQLite path: path is empty or missing. Received: ${JSON.stringify(rawPath)}`); } - if (/^(?:file|sqlite):\/\//i.test(trimmed)) { - try { - const parsed = new URL(trimmed); - const hostname = parsed.hostname || ""; - const pathname = parsed.pathname || ""; - - if (hostname && /^[A-Za-z]$/.test(hostname) && pathname.startsWith("/")) { - return `${hostname}:${pathname}`; - } - - if (!hostname && /^\/[A-Za-z]:\//.test(pathname)) { - return pathname.slice(1); - } + if (isWindowsDriveRootPath(dbPath)) { + throw new Error( + `Invalid SQLite path "${dbPath}" - it points to a Windows drive root, not a database file.` + ); + } - return decodeURIComponent(`${hostname}${pathname}`); - } catch { - return trimmed; + if (fs.existsSync(dbPath)) { + const stat = fs.statSync(dbPath); + if (stat.isDirectory()) { + throw new Error(`Invalid SQLite path "${dbPath}" - it points to a directory, not a database file.`); } + return dbPath; } - return trimmed; + if (options.requireExistingFile) { + throw new Error(`Database file does not exist at path: ${dbPath}`); + } + + return dbPath; } function findExistingBindingPath(candidates: Iterable): string | undefined { @@ -403,7 +402,9 @@ function resolvePkgNativeBindingPath(): string | undefined { function openDB(cfg: SQLiteConfig, extraOptions: Database.Options = {}): Database.Database { const nativeBinding = resolvePkgNativeBindingPath(); - const dbPath = normalizeSQLitePath(cfg.path); + const dbPath = validateSQLitePath(cfg.path, { + requireExistingFile: Boolean(extraOptions.fileMustExist), + }); const options: Database.Options = { readonly: cfg.readonly ?? false, ...extraOptions, @@ -427,18 +428,8 @@ function quoteIdent(name: string): string { /** Test connection to SQLite database (checks if file is accessible) */ export async function testConnection(cfg: SQLiteConfig): Promise<{ ok: boolean; message?: string; status: 'connected' | 'disconnected' }> { try { - const dbPath = normalizeSQLitePath(cfg.path); + const dbPath = validateSQLitePath(cfg.path, { requireExistingFile: true }); - // Validate the path field exists - if (!dbPath || typeof dbPath !== "string" || !dbPath.trim()) { - return { - ok: false, - status: "disconnected", - message: `Invalid SQLite path: path is empty or missing. Received: ${JSON.stringify(cfg)}`, - }; - } - - // Detect truncated Windows drive-letter-only paths (e.g. "C:" or "D:") if (/^[A-Za-z]:$/.test(dbPath)) { return { ok: false, @@ -446,15 +437,6 @@ export async function testConnection(cfg: SQLiteConfig): Promise<{ ok: boolean; message: `Invalid SQLite path "${cfg.path}" — looks like a truncated Windows drive letter. Please re-add the database with the full file path.`, }; } - - // Ensure the database file exists to avoid implicitly creating a new empty DB - if (!fs.existsSync(dbPath)) { - return { - ok: false, - status: "disconnected", - message: `Database file does not exist at path: ${dbPath}`, - }; - } // Use fileMustExist so better-sqlite3 will not create a new file during connection test const db = openDB({ ...cfg, path: dbPath }, { fileMustExist: true, diff --git a/bridge/src/services/connectionBuilder.ts b/bridge/src/services/connectionBuilder.ts index 24fce7e..4c9ec34 100644 --- a/bridge/src/services/connectionBuilder.ts +++ b/bridge/src/services/connectionBuilder.ts @@ -1,42 +1,6 @@ import { DBType, DatabaseConfig } from "../types"; import { SQLiteConfig } from "../types/sqlite"; - -function normalizeSQLitePath(rawPath: unknown): string { - if (typeof rawPath !== "string") { - return ""; - } - - const trimmed = rawPath.trim(); - if (!trimmed) { - return ""; - } - - if (/^\/[A-Za-z]:\//.test(trimmed)) { - return trimmed.slice(1); - } - - if (/^(?:file|sqlite):\/\//i.test(trimmed)) { - try { - const parsed = new URL(trimmed); - const hostname = parsed.hostname || ""; - const pathname = parsed.pathname || ""; - - if (hostname && /^[A-Za-z]$/.test(hostname) && pathname.startsWith("/")) { - return `${hostname}:${pathname}`; - } - - if (!hostname && /^\/[A-Za-z]:\//.test(pathname)) { - return pathname.slice(1); - } - - return decodeURIComponent(`${hostname}${pathname}`); - } catch { - return trimmed; - } - } - - return trimmed; -} +import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath"; export class ConnectionBuilder { static buildConnection( @@ -52,6 +16,11 @@ export class ConnectionBuilder { `Got database=${JSON.stringify(db.database)}, path=${JSON.stringify(db.path)}` ); } + if (isWindowsDriveRootPath(dbPath)) { + throw new Error( + `Invalid SQLite path "${dbPath}" — it points to a Windows drive root, not a database file.` + ); + } return { path: dbPath, readonly: db.readonly ?? false, diff --git a/bridge/src/services/databaseService.ts b/bridge/src/services/databaseService.ts index 745a9a9..273e601 100644 --- a/bridge/src/services/databaseService.ts +++ b/bridge/src/services/databaseService.ts @@ -53,11 +53,33 @@ export class DatabaseService { for (const field of required) { if (!payload[field]) throw new Error(`Missing required field: ${field}`); } + if (isSQLite) { + const sqliteConfig = ConnectionBuilder.buildSQLiteConnection(payload); + payload = { + ...payload, + database: sqliteConfig.path, + }; + } return dbStoreInstance.addDB(payload as Parameters[0]); } async updateDatabase(id: string, payload: Record) { if (!id) throw new Error("Missing id"); + const isSQLite = (payload.type as string | undefined)?.toLowerCase().includes("sqlite"); + if (isSQLite || typeof payload.database === "string") { + const current = await dbStoreInstance.getDB(id); + const currentIsSQLite = (current?.type as string | undefined)?.toLowerCase().includes("sqlite"); + if (currentIsSQLite || isSQLite) { + const sqliteConfig = ConnectionBuilder.buildSQLiteConnection({ + ...current, + ...payload, + }); + payload = { + ...payload, + database: sqliteConfig.path, + }; + } + } connectionPool.invalidate(id); // evict stale cached config return dbStoreInstance.updateDB(id, payload as Parameters[1]); } diff --git a/bridge/src/services/dbStore.ts b/bridge/src/services/dbStore.ts index 74e6152..641436a 100644 --- a/bridge/src/services/dbStore.ts +++ b/bridge/src/services/dbStore.ts @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from "uuid"; import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "crypto"; import { promisify } from "util"; import { CONFIG_FOLDER, CONFIG_FILE, CREDENTIALS_FILE } from "../utils/config"; +import { normalizeSQLitePath } from "../utils/sqlitePath"; const scryptAsync = promisify(scrypt); /** Path of the app-level random secret used for password encryption */ @@ -195,6 +196,51 @@ type ConfigData = { databases: DBMeta[]; }; +function normalizeSQLiteDatabaseValue(rawPath: unknown): string { + return normalizeSQLitePath(rawPath); +} + +function normalizeSQLiteMeta>(meta: T): T { + const isSQLite = typeof meta.type === "string" && meta.type.toLowerCase().includes("sqlite"); + if (!isSQLite) { + return meta; + } + + const normalizedDatabase = normalizeSQLiteDatabaseValue(meta.database); + if (!normalizedDatabase || normalizedDatabase === meta.database) { + return meta; + } + + return { + ...meta, + database: normalizedDatabase, + } as T; +} + +function normalizeConfigData(data: ConfigData): { changed: boolean; data: ConfigData } { + let changed = false; + + const databases = data.databases.map((db) => { + const normalized = normalizeSQLiteMeta(db); + if (normalized !== db) { + changed = true; + } + return normalized; + }); + + if (!changed) { + return { changed: false, data }; + } + + return { + changed: true, + data: { + ...data, + databases, + }, + }; +} + /** * Database Store Service * Handles persistence and encryption of database connections @@ -244,8 +290,15 @@ export class DbStore { // Load config file if (fsSync.existsSync(this.configFile)) { const configData = await fs.readFile(this.configFile, "utf-8"); - const config = JSON.parse(configData); - this.cache.setConfig(config); + const normalized = normalizeConfigData(JSON.parse(configData)); + if (normalized.changed) { + await fs.writeFile( + this.configFile, + JSON.stringify(normalized.data, null, 2), + "utf-8" + ); + } + this.cache.setConfig(normalized.data); } else { // Create empty config and cache it const emptyConfig: ConfigData = { version: 1, databases: [] }; @@ -411,22 +464,26 @@ export class DbStore { await this.ensureConfigDir(); const txt = await fs.readFile(this.configFile, "utf-8"); - const config = JSON.parse(txt); - this.cache.setConfig(config); - return config; + const normalized = normalizeConfigData(JSON.parse(txt)); + if (normalized.changed) { + await fs.writeFile(this.configFile, JSON.stringify(normalized.data, null, 2), "utf-8"); + } + this.cache.setConfig(normalized.data); + return normalized.data; } /** * Save all database configurations (invalidates cache) */ private async saveAll(data: ConfigData): Promise { + const normalized = normalizeConfigData(data).data; // Only ensure directory exists, don't call ensureConfigDir to avoid recursion if (!fsSync.existsSync(this.configFolder)) { await fs.mkdir(this.configFolder, { recursive: true }); } - await fs.writeFile(this.configFile, JSON.stringify(data, null, 2), "utf-8"); + await fs.writeFile(this.configFile, JSON.stringify(normalized, null, 2), "utf-8"); // Update cache with new data - this.cache.setConfig(data); + this.cache.setConfig(normalized); } /** @@ -476,7 +533,7 @@ export class DbStore { const now = new Date().toISOString(); const credentialId = payload.password ? `db_${id}` : undefined; - const meta: DBMeta = { + const meta: DBMeta = normalizeSQLiteMeta({ id, name: payload.name, host: payload.host, @@ -491,7 +548,7 @@ export class DbStore { tags: payload.tags || [], createdAt: now, updatedAt: now, - }; + }); all.databases.push(meta); await this.saveAll(all); @@ -525,11 +582,11 @@ export class DbStore { const now = new Date().toISOString(); const curr = all.databases[idx]; - const updated = { + const updated = normalizeSQLiteMeta({ ...curr, ...patch, updatedAt: now, - }; + }); if (patch.password) { const credentialId = updated.credentialId || `db_${id}`; diff --git a/bridge/src/utils/sqlitePath.ts b/bridge/src/utils/sqlitePath.ts new file mode 100644 index 0000000..8f4f63c --- /dev/null +++ b/bridge/src/utils/sqlitePath.ts @@ -0,0 +1,40 @@ +export function normalizeSQLitePath(rawPath: unknown): string { + if (typeof rawPath !== "string") { + return ""; + } + + const trimmed = rawPath.trim(); + if (!trimmed) { + return ""; + } + + if (/^\/[A-Za-z]:\//.test(trimmed)) { + return trimmed.slice(1); + } + + if (/^(?:file|sqlite):\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + const hostname = parsed.hostname || ""; + const pathname = parsed.pathname || ""; + + if (hostname && /^[A-Za-z]$/.test(hostname) && pathname.startsWith("/")) { + return `${hostname}:${pathname}`; + } + + if (!hostname && /^\/[A-Za-z]:\//.test(pathname)) { + return pathname.slice(1); + } + + return decodeURIComponent(`${hostname}${pathname}`); + } catch { + return trimmed; + } + } + + return trimmed; +} + +export function isWindowsDriveRootPath(dbPath: string): boolean { + return /^[A-Za-z]:(?:[\\/])?$/.test(dbPath); +} From ca697fcbdad1c122fc3268135c7c20418598ca86 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 9 Apr 2026 20:33:59 +0530 Subject: [PATCH 2/3] test: add SQLite path validation tests and normalize paths in DbStore --- bridge/__tests__/connectionBuilder.test.ts | 7 +++ bridge/__tests__/connectors/sqlite.test.ts | 7 +++ bridge/__tests__/databaseService.test.ts | 44 +++++++++++++++++- bridge/__tests__/dbStore.test.ts | 54 ++++++++++++++++++++++ bridge/tsconfig.json | 1 - 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/bridge/__tests__/connectionBuilder.test.ts b/bridge/__tests__/connectionBuilder.test.ts index 281ca40..5e050d0 100644 --- a/bridge/__tests__/connectionBuilder.test.ts +++ b/bridge/__tests__/connectionBuilder.test.ts @@ -180,6 +180,13 @@ describe("ConnectionBuilder", () => { expect(config.readonly).toBe(true); }); + test("should reject Windows drive roots for SQLite", () => { + const dbInput = { database: "D:/" }; + expect(() => ConnectionBuilder.buildSQLiteConnection(dbInput)).toThrow( + 'Invalid SQLite path "D:/"' + ); + }); + test("buildConnection with DBType.SQLITE should return SQLiteConfig", () => { const dbInput = { database: "/tmp/test.db" }; const config = ConnectionBuilder.buildConnection(dbInput, null, DBType.SQLITE) as SQLiteConfig; diff --git a/bridge/__tests__/connectors/sqlite.test.ts b/bridge/__tests__/connectors/sqlite.test.ts index fbab2a2..5e08256 100644 --- a/bridge/__tests__/connectors/sqlite.test.ts +++ b/bridge/__tests__/connectors/sqlite.test.ts @@ -98,6 +98,13 @@ describe("SQLite Connector", () => { expect(connection.status).toBe("disconnected"); expect(connection.message).toBeDefined(); }); + + test("Should fail cleanly when SQLite path is a directory", async () => { + const connection = await sqliteConnector.testConnection({ path: tmpDir }); + expect(connection.ok).toBe(false); + expect(connection.status).toBe("disconnected"); + expect(connection.message).toContain("directory"); + }); }); // =============================== diff --git a/bridge/__tests__/databaseService.test.ts b/bridge/__tests__/databaseService.test.ts index 6011b72..b6c326a 100644 --- a/bridge/__tests__/databaseService.test.ts +++ b/bridge/__tests__/databaseService.test.ts @@ -1,5 +1,14 @@ import { afterAll, describe, expect, test } from "@jest/globals"; -import { DatabaseService } from "../src/services/databaseService"; +import fs from "fs/promises"; +import fsSync from "fs"; +import os from "os"; +import path from "path"; + +const TEST_RELWAVE_HOME = path.join(os.tmpdir(), `database-service-test-${Date.now()}`); +process.env.RELWAVE_HOME = TEST_RELWAVE_HOME; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { DatabaseService } = require("../src/services/databaseService"); const mockInput = { name: "TestDB", @@ -46,6 +55,10 @@ describe("Database Service Method", () => { } catch (e) { // Ignore errors during final cleanup } + + if (fsSync.existsSync(TEST_RELWAVE_HOME)) { + await fs.rm(TEST_RELWAVE_HOME, { recursive: true, force: true }); + } }); // Test Case 1: All required fields provided @@ -60,6 +73,35 @@ describe("Database Service Method", () => { expect(result.name).toBe(payload.name); }); + test("should normalize SQLite database paths when adding a database", async () => { + const payload = { + name: "SQLiteTestDB", + host: "", + port: 0, + user: "", + database: "sqlite:///C:/Users/test/relwave.db", + type: "sqlite", + }; + + const result = await dbService.addDatabase(payload); + createdDbIds.push(result.id); + + expect(result.database).toBe("C:/Users/test/relwave.db"); + }); + + test("should reject Windows drive roots for SQLite databases", async () => { + const payload = { + name: "SQLiteDriveRootDB", + host: "", + port: 0, + user: "", + database: "D:/", + type: "sqlite", + }; + + await expect(dbService.addDatabase(payload)).rejects.toThrow('Invalid SQLite path "D:/"'); + }); + // Test Case 2: Missing required field 'host' test("should throw error when required field 'host' is missing", async () => { diff --git a/bridge/__tests__/dbStore.test.ts b/bridge/__tests__/dbStore.test.ts index 1c4d76f..14601da 100644 --- a/bridge/__tests__/dbStore.test.ts +++ b/bridge/__tests__/dbStore.test.ts @@ -25,6 +25,15 @@ const mockDBPayload = { password: "testpassword123", }; +const mockSQLitePayload = { + name: "SQLiteDB", + host: "", + port: 0, + user: "", + database: "sqlite:///C:/Users/test/example.db", + type: "sqlite", +}; + describe("DbStore Cache Tests", () => { let dbStore: DbStore; @@ -91,6 +100,15 @@ describe("DbStore Cache Tests", () => { const deleted = await dbStore.getDB(result.id); expect(deleted).toBeUndefined(); }); + + test("should normalize stored SQLite database paths", async () => { + const result = await dbStore.addDB(mockSQLitePayload); + + expect(result.database).toBe("C:/Users/test/example.db"); + + const retrieved = await dbStore.getDB(result.id); + expect(retrieved?.database).toBe("C:/Users/test/example.db"); + }); }); describe("Password Encryption", () => { @@ -331,6 +349,42 @@ describe("DbStore Cache Tests", () => { expect(manualStore.isReady()).toBe(true); expect(manualStore.getCacheStats().configCached).toBe(true); }); + + test("should normalize SQLite paths while preloading existing config data", async () => { + const existingConfig = { + version: 1, + databases: [ + { + id: "sqlite-1", + name: "SQLite Existing", + host: "", + port: 0, + user: "", + database: "file:///C:/Users/test/preloaded.db", + type: "sqlite", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + }; + + await fs.writeFile(TEST_CONFIG_FILE, JSON.stringify(existingConfig, null, 2), "utf-8"); + + const preloadStore = new DbStore( + TEST_CONFIG_FOLDER, + TEST_CONFIG_FILE, + TEST_CREDENTIALS_FILE, + NORMAL_CACHE_TTL, + true + ); + await preloadStore.waitUntilReady(); + + const db = await preloadStore.getDB("sqlite-1"); + expect(db?.database).toBe("C:/Users/test/preloaded.db"); + + const persisted = JSON.parse(await fs.readFile(TEST_CONFIG_FILE, "utf-8")); + expect(persisted.databases[0].database).toBe("C:/Users/test/preloaded.db"); + }); }); describe("Cache Performance", () => { diff --git a/bridge/tsconfig.json b/bridge/tsconfig.json index a3e4726..337ebac 100644 --- a/bridge/tsconfig.json +++ b/bridge/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "module": "CommonJS", "target": "ES2020", - "moduleResolution": "node", "outDir": "./dist", "rootDir": "./src", "esModuleInterop": true, From e104b4b7092367960b4dbd29c2e194832b9f21c5 Mon Sep 17 00:00:00 2001 From: Yash Date: Thu, 9 Apr 2026 20:38:59 +0530 Subject: [PATCH 3/3] fix(whats-new-dialog): ensure dialog displays only on version change --- src/components/shared/WhatsNewDialog.tsx | 31 ++++++++++++++---------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/shared/WhatsNewDialog.tsx b/src/components/shared/WhatsNewDialog.tsx index 3893717..666c372 100644 --- a/src/components/shared/WhatsNewDialog.tsx +++ b/src/components/shared/WhatsNewDialog.tsx @@ -142,27 +142,32 @@ export function WhatsNewDialog() { const raw = localStorage.getItem(LAST_INSTALLED_UPDATE_KEY); const lastSeenVersion = localStorage.getItem(LAST_SEEN_WHATS_NEW_VERSION_KEY); - if (!raw) return; + // Only show the dialog when the app version actually changed. + // This covers both updater-driven installs and manual installer upgrades. + const versionChanged = Boolean(lastSeenVersion && lastSeenVersion !== installedVersion); + + if (!raw && !versionChanged) return; let parsed: StoredInstalledUpdate | null = null; - try { - parsed = JSON.parse(raw) as StoredInstalledUpdate; - } catch { - localStorage.removeItem(LAST_INSTALLED_UPDATE_KEY); - return; + if (raw) { + try { + parsed = JSON.parse(raw) as StoredInstalledUpdate; + } catch { + localStorage.removeItem(LAST_INSTALLED_UPDATE_KEY); + } } - if (!parsed?.version) { - localStorage.removeItem(LAST_INSTALLED_UPDATE_KEY); - return; - } + const updateVersion = parsed?.version || installedVersion; - if (parsed.version !== installedVersion) { + if (!versionChanged && updateVersion !== installedVersion) { return; } - if (lastSeenVersion === installedVersion) { - return; + if (!parsed?.version || parsed.version !== installedVersion) { + parsed = { + version: installedVersion, + previousVersion: lastSeenVersion || undefined, + }; } setReleaseInfo(parsed);