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.`, + ); +}