From ac5da581e2892b571ff5c07509c70842bebd25fe Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 28 Jul 2026 01:48:48 +0800 Subject: [PATCH] refuse to start with an empty database when data exists elsewhere (#1118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the data directory holds no database, startup treats it as a first run and silently creates an empty one. A deployment that loses DATA_DIR — an .env file the service no longer loads, a volume that did not mount — lands in exactly that state, so the user is asked to register an admin account again while the real database sits untouched one directory over. It is indistinguishable from the upgrade having deleted everything. Check the known data locations before creating a new database and refuse to start when one of them already holds a database, naming both directories. ALLOW_EMPTY_DATA_DIR=true starts anyway for anyone deliberately starting over. This matches how a failed decryption already behaves: it throws rather than falling back to an empty database. Closes Termix-SSH/Support#1006 --- src/backend/database/db/index.ts | 10 ++ .../tests/utils/data-dir-guard.test.ts | 133 ++++++++++++++++++ src/backend/utils/data-dir-guard.ts | 88 ++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/backend/tests/utils/data-dir-guard.test.ts create mode 100644 src/backend/utils/data-dir-guard.ts diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index b3f0f97b..f40dc0d1 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -8,6 +8,10 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js" import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseMigration } from "../../utils/database-migration.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { + assertDataDirIsNotMisconfigured, + DataDirMisconfiguredError, +} from "../../utils/data-dir-guard.js"; import { getDefaultGuacdUrl } from "../../utils/guacd-config.js"; const dataDir = process.env.DATA_DIR || "./db/data"; @@ -104,11 +108,16 @@ async function initializeDatabaseAsync(): Promise { ); } } else { + assertDataDirIsNotMisconfigured(dataDir); memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } } catch (error) { + // Not a decryption problem: the database is fine, we are pointed at the + // wrong directory. Surface that message as-is. + if (error instanceof DataDirMisconfiguredError) throw error; + databaseLogger.error("Failed to initialize memory database", error, { operation: "db_memory_init_failed", errorMessage: error instanceof Error ? error.message : "Unknown error", @@ -145,6 +154,7 @@ async function initializeDatabaseAsync(): Promise { ); } } else { + assertDataDirIsNotMisconfigured(dataDir); memoryDatabase = new Database(":memory:"); isNewDatabase = true; } diff --git a/src/backend/tests/utils/data-dir-guard.test.ts b/src/backend/tests/utils/data-dir-guard.test.ts new file mode 100644 index 00000000..1dfbfe17 --- /dev/null +++ b/src/backend/tests/utils/data-dir-guard.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + ALLOW_EMPTY_DATA_DIR_ENV, + assertDataDirIsNotMisconfigured, + DataDirMisconfiguredError, + findDatabaseOutsideDataDir, +} from "../../utils/data-dir-guard.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-datadir-")); + tempDirs.push(dir); + return dir; +} + +/** Writes a plain (unencrypted) database file into `dir`. */ +function writePlainDatabase(dir: string, size = 4096): string { + fs.mkdirSync(dir, { recursive: true }); + const dbPath = path.join(dir, "db.sqlite"); + fs.writeFileSync(dbPath, Buffer.alloc(size, 1)); + return dbPath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("findDatabaseOutsideDataDir", () => { + it("returns null on a genuinely fresh install", () => { + const cwd = makeTempDir(); + const dataDir = path.join(cwd, "db", "data"); + + expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull(); + }); + + it("finds a database left in the legacy data directory", () => { + const cwd = makeTempDir(); + const legacyDir = path.join(cwd, "data"); + writePlainDatabase(legacyDir); + + expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe( + legacyDir, + ); + }); + + it("finds a database under the default directory when DATA_DIR points elsewhere", () => { + const cwd = makeTempDir(); + const defaultDir = path.join(cwd, "db", "data"); + writePlainDatabase(defaultDir); + + expect(findDatabaseOutsideDataDir("/mnt/unmounted-volume", cwd)).toBe( + defaultDir, + ); + }); + + it("ignores the configured data directory itself", () => { + const cwd = makeTempDir(); + const dataDir = path.join(cwd, "data"); + writePlainDatabase(dataDir); + + expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull(); + }); + + it("ignores a zero-length database file", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data"), 0); + + expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe( + null, + ); + }); +}); + +describe("assertDataDirIsNotMisconfigured", () => { + it("passes when no database exists anywhere else", () => { + const cwd = makeTempDir(); + + expect(() => + assertDataDirIsNotMisconfigured(path.join(cwd, "db", "data"), {}, cwd), + ).not.toThrow(); + }); + + it("refuses to start and names both directories", () => { + const cwd = makeTempDir(); + const legacyDir = path.join(cwd, "data"); + writePlainDatabase(legacyDir); + const dataDir = path.join(cwd, "db", "data"); + + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + DataDirMisconfiguredError, + ); + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + new RegExp(`${legacyDir}`), + ); + expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow( + new RegExp(`${dataDir}`), + ); + }); + + it("can be overridden to start with a new database", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data")); + + for (const value of ["true", "1", "YES", "on"]) { + expect(() => + assertDataDirIsNotMisconfigured( + path.join(cwd, "db", "data"), + { [ALLOW_EMPTY_DATA_DIR_ENV]: value }, + cwd, + ), + ).not.toThrow(); + } + }); + + it("still refuses when the override is not a truthy value", () => { + const cwd = makeTempDir(); + writePlainDatabase(path.join(cwd, "data")); + + expect(() => + assertDataDirIsNotMisconfigured( + path.join(cwd, "db", "data"), + { [ALLOW_EMPTY_DATA_DIR_ENV]: "false" }, + cwd, + ), + ).toThrow(DataDirMisconfiguredError); + }); +}); diff --git a/src/backend/utils/data-dir-guard.ts b/src/backend/utils/data-dir-guard.ts new file mode 100644 index 00000000..0fabc6c3 --- /dev/null +++ b/src/backend/utils/data-dir-guard.ts @@ -0,0 +1,88 @@ +import fs from "fs"; +import path from "path"; +import { DatabaseFileEncryption } from "./database-file-encryption.js"; + +export const ALLOW_EMPTY_DATA_DIR_ENV = "ALLOW_EMPTY_DATA_DIR"; + +/** Thrown when the data directory looks misconfigured rather than empty. */ +export class DataDirMisconfiguredError extends Error { + constructor(message: string) { + super(message); + this.name = "DataDirMisconfiguredError"; + } +} + +/** + * Directories Termix has shipped or documented as a data location. A deployment + * that loses DATA_DIR — an unloaded .env file, an unmounted volume — falls back + * to the default and finds an empty directory, which is indistinguishable from a + * first run. Checking these tells the two apart. + */ +const KNOWN_DATA_DIRS = ["db/data", "data", "/app/data"]; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function hasDatabaseFile(dir: string): boolean { + const dbPath = path.join(dir, "db.sqlite"); + + if (DatabaseFileEncryption.isEncryptedDatabaseFile(`${dbPath}.encrypted`)) { + return true; + } + + try { + return fs.statSync(dbPath).size > 0; + } catch { + return false; + } +} + +/** + * Looks for a database outside the configured data directory. Returns the + * directory holding it, or null when this really is a fresh install. + */ +export function findDatabaseOutsideDataDir( + dataDir: string, + cwd: string = process.cwd(), +): string | null { + const resolvedDataDir = path.resolve(dataDir); + + for (const candidate of KNOWN_DATA_DIRS) { + const dir = path.resolve(cwd, candidate); + if (dir === resolvedDataDir) continue; + if (hasDatabaseFile(dir)) return dir; + } + + return null; +} + +export function isEmptyDataDirAllowed( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return TRUE_VALUES.has( + env[ALLOW_EMPTY_DATA_DIR_ENV]?.trim().toLowerCase() ?? "", + ); +} + +/** + * Refuses to start with a blank database when an existing one sits elsewhere. + * Creating a fresh database in that state looks exactly like data loss: the user + * is asked to register an admin account again while their real data is intact + * one directory over. + */ +export function assertDataDirIsNotMisconfigured( + dataDir: string, + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): void { + if (isEmptyDataDirAllowed(env)) return; + + const existing = findDatabaseOutsideDataDir(dataDir, cwd); + if (!existing) return; + + throw new DataDirMisconfiguredError( + `No database found in DATA_DIR (${path.resolve(dataDir)}), but an existing database is present in ${existing}. ` + + `Starting here would create an empty database and hide your data. ` + + `Set DATA_DIR=${existing} (check that your .env file is loaded and any volume is mounted), ` + + `or set ${ALLOW_EMPTY_DATA_DIR_ENV}=true to start with a new database anyway.`, + ); +}