From 32fb7487dfb4833f9d035bd8691a2bbbc67bbc28 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 29 Jul 2026 18:38:10 +0800 Subject: [PATCH] load the database file when encryption is off (#1137) --- src/backend/database/db/index.ts | 30 ++++++- .../db/unencrypted-persistence.test.ts | 80 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/backend/tests/database/db/unencrypted-persistence.test.ts diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index ee8ca5b2..4315533c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -156,8 +156,34 @@ async function initializeDatabaseAsync(): Promise { } } else { assertDataDirIsNotMisconfigured(dataDir); - memoryDatabase = new Database(":memory:"); - isNewDatabase = true; + + // The database still lives in memory and is serialised out on every write; + // turning encryption off only changes whether that file is ciphertext. It + // has to be read back, or each restart starts empty and silently discards + // everything the previous run saved. + const existing = readPlainDatabaseFile(); + if (existing) { + memoryDatabase = new Database(existing); + databaseLogger.info("Loaded unencrypted database from disk", { + operation: "db_load_plain", + path: dbPath, + bytes: existing.length, + }); + } else { + memoryDatabase = new Database(":memory:"); + isNewDatabase = true; + } + } +} + +/** The plain database file, or null when there is nothing to restore. */ +function readPlainDatabaseFile(): Buffer | null { + try { + const contents = fs.readFileSync(dbPath); + return contents.length > 0 ? contents : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; } } diff --git a/src/backend/tests/database/db/unencrypted-persistence.test.ts b/src/backend/tests/database/db/unencrypted-persistence.test.ts new file mode 100644 index 00000000..1e0a0111 --- /dev/null +++ b/src/backend/tests/database/db/unencrypted-persistence.test.ts @@ -0,0 +1,80 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `DB_FILE_ENCRYPTION=false` used to mean "start empty, every time". + * + * The database lives in memory on every backend and is serialised to disk after + * writes; the flag only decides whether that file is ciphertext. The plain + * branch wrote `db.sqlite` faithfully and then never read it back, so each + * restart began with an empty database and silently discarded everything the + * previous run had saved. The data-dir guard made it worse by confirming a + * database was present in DATA_DIR immediately before it was thrown away. + */ +describe("unencrypted database persistence", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plain-db-")); + vi.resetModules(); + process.env.DATA_DIR = dataDir; + process.env.DB_FILE_ENCRYPTION = "false"; + process.env.ALLOW_EMPTY_DATA_DIR = "true"; + }); + + afterEach(() => { + delete process.env.DATA_DIR; + delete process.env.DB_FILE_ENCRYPTION; + delete process.env.ALLOW_EMPTY_DATA_DIR; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + /** A database file with one row, as a previous run would have left it. */ + function writeExistingDatabase(): void { + const seed = new Database(":memory:"); + seed.exec("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)"); + seed + .prepare("INSERT INTO settings (key, value) VALUES (?, ?)") + .run("survives_restart", "yes"); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + it("reads back what an earlier run wrote", async () => { + writeExistingDatabase(); + + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + + const row = db + .getSqlite() + .prepare("SELECT value FROM settings WHERE key = ?") + .get("survives_restart") as { value: string } | undefined; + + expect(row?.value).toBe("yes"); + }); + + it("starts empty when there is no file yet", async () => { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + + // Startup creates its own tables; the point is that it does not throw on a + // missing file and does not carry rows over from nowhere. + const row = db + .getSqlite() + .prepare("SELECT COUNT(*) AS count FROM users") + .get() as { count: number }; + + expect(row.count).toBe(0); + }); + + it("ignores a zero-length file rather than failing to open it", async () => { + fs.writeFileSync(path.join(dataDir, "db.sqlite"), ""); + + const db = await import("../../../database/db/index.js"); + await expect(db.initializeDatabase()).resolves.not.toThrow(); + }); +});