From 9d91c31027253877aa98afc4af251fb5bf78ee2f Mon Sep 17 00:00:00 2001 From: Yuzu Date: Wed, 22 Jul 2026 18:43:46 +0700 Subject: [PATCH] fix: never replace an unreadable database file with an empty one --- .github/workflows/docker.yml | 45 ++++------- src/backend/database/db/index.ts | 80 ++++++++++++++++++- .../concurrent-save-serialization.test.ts | 56 +++++++++++++ .../unreadable-database-guard.test.ts | 65 +++++++++++++++ src/backend/utils/database-file-encryption.ts | 55 +++++++++++-- 5 files changed, 265 insertions(+), 36 deletions(-) create mode 100644 src/backend/tests/database/concurrent-save-serialization.test.ts create mode 100644 src/backend/tests/database/unreadable-database-guard.test.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 212f62c..4b5ae3f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,7 +42,10 @@ on: jobs: build: - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout repository uses: actions/checkout@v7 @@ -53,42 +56,31 @@ jobs: - name: Resolve source revision run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - with: - platforms: linux/amd64,linux/arm64 - - name: Setup Docker Buildx - uses: useblacksmith/setup-docker-builder@v1 + uses: docker/setup-buildx-action@v3 - name: Determine tags id: tags run: | VERSION=${{ inputs.version }} BUILD_TYPE=${{ inputs.build_type }} + IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" TAGS=() ALL_TAGS=() if [ "$BUILD_TYPE" = "Production" ]; then TAGS+=("release-$VERSION" "$VERSION" "latest") - for tag in "${TAGS[@]}"; do - ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") - ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag") - done elif [ "$BUILD_TYPE" = "Beta" ]; then TAGS+=("beta" "beta-$VERSION") - for tag in "${TAGS[@]}"; do - ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") - ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag") - done else TAGS+=("dev-$VERSION") - for tag in "${TAGS[@]}"; do - ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") - done fi + for tag in "${TAGS[@]}"; do + ALL_TAGS+=("$IMAGE:$tag") + done + echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV - name: Login to GHCR @@ -96,23 +88,16 @@ jobs: uses: docker/login-action@v4 with: registry: ghcr.io - username: lukegus - password: ${{ secrets.GHCR_TOKEN }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Login to Docker Hub (prod and beta only) - if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }} - uses: docker/login-action@v4 - with: - username: bugattiguy527 - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push multi-arch image - uses: useblacksmith/build-push-action@v2 + - name: Build and push image + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: ${{ !inputs.dry_run }} - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 tags: ${{ env.ALL_TAGS }} build-args: | BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index cb9c358..f9f519c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -25,6 +25,30 @@ let memoryDatabase: Database.Database; let isNewDatabase = false; let sqlite: Database.Database; +export const ALLOW_UNREADABLE_DB_RESET_ENV = "ALLOW_UNREADABLE_DB_RESET"; +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +export class UnreadableDatabaseFileError extends Error { + constructor(message: string) { + super(message); + this.name = "UnreadableDatabaseFileError"; + } +} + +function allowResetOnUnreadableDatabase(): boolean { + return TRUE_VALUES.has( + process.env[ALLOW_UNREADABLE_DB_RESET_ENV]?.trim().toLowerCase() ?? "", + ); +} + +function fileSizeOrUnknown(filePath: string): number { + try { + return fs.statSync(filePath).size; + } catch { + return -1; + } +} + function getRawSettingValue(key: string): string | null { const row = sqlite .prepare("SELECT value FROM settings WHERE key = ?") @@ -67,6 +91,46 @@ async function initializeDatabaseAsync(): Promise { // expected - sessions table may not exist yet } } else { + // The file is present but its header could not be read, so treating + // this as a fresh install would overwrite it with an empty database. + if (fs.existsSync(encryptedDbPath)) { + const fileSize = fileSizeOrUnknown(encryptedDbPath); + + if (!allowResetOnUnreadableDatabase()) { + databaseLogger.error( + "Encrypted database file exists but could not be recognized - refusing to start", + null, + { + operation: "db_unreadable_refuse_start", + encryptedDbPath, + fileSize, + envKey: ALLOW_UNREADABLE_DB_RESET_ENV, + }, + ); + throw new UnreadableDatabaseFileError( + `${encryptedDbPath} exists (${fileSize} bytes) but is not a valid encrypted database.\n` + + `Refusing to start, because initializing a new database would overwrite this file and destroy any data still recoverable from it.\n` + + `\nLikely causes:\n` + + `1. The file was truncated by an unclean shutdown, host crash, or storage failure\n` + + `2. The file was partially restored or copied\n` + + `\nWhat to do:\n` + + `- Keep a copy of ${encryptedDbPath} before doing anything else\n` + + `- Restore a backup from ${path.join(dataDir, "backups")}, or a filesystem/volume snapshot\n` + + `- If you accept losing this database, set ${ALLOW_UNREADABLE_DB_RESET_ENV}=1 to discard it and start fresh`, + ); + } + + databaseLogger.warn( + "Discarding unreadable encrypted database and starting fresh - explicitly enabled", + { + operation: "db_unreadable_reset_allowed", + encryptedDbPath, + fileSize, + envKey: ALLOW_UNREADABLE_DB_RESET_ENV, + }, + ); + } + const migration = new DatabaseMigration(dataDir); const migrationStatus = migration.checkMigrationStatus(); @@ -109,6 +173,10 @@ async function initializeDatabaseAsync(): Promise { } } } catch (error) { + if (error instanceof UnreadableDatabaseFileError) { + throw error; + } + databaseLogger.error("Failed to initialize memory database", error, { operation: "db_memory_init_failed", errorMessage: error instanceof Error ? error.message : "Unknown error", @@ -2211,7 +2279,17 @@ const migrateSchema = () => { }); }; -async function saveMemoryDatabaseToFile(): Promise { +// Callers here do not coordinate with each other, and only DatabaseSaveTrigger +// tracks whether a save is already running. Overlapping saves can write an +// older snapshot last, so run them in order. +let saveQueue: Promise = Promise.resolve(); + +function saveMemoryDatabaseToFile(): Promise { + saveQueue = saveQueue.then(() => writeMemoryDatabaseToDisk()); + return saveQueue; +} + +async function writeMemoryDatabaseToDisk(): Promise { if (!memoryDatabase) return; try { diff --git a/src/backend/tests/database/concurrent-save-serialization.test.ts b/src/backend/tests/database/concurrent-save-serialization.test.ts new file mode 100644 index 0000000..f5e15ec --- /dev/null +++ b/src/backend/tests/database/concurrent-save-serialization.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from "vitest"; +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"; + +const tempDirs: string[] = []; +const originalEnv = { ...process.env }; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-save-race-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + process.env = { ...originalEnv }; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("concurrent encrypted writes", () => { + it("leaves a readable file when saves overlap", async () => { + const dataDir = makeTempDir(); + const target = path.join(dataDir, "db.sqlite.encrypted"); + process.env.DATABASE_KEY = "b".repeat(64); + + // whichever lands last, the file must still parse + const payloads = Array.from({ length: 12 }, () => + crypto.randomBytes(64 * 1024), + ); + + await Promise.all( + payloads.map((payload) => + DatabaseFileEncryption.encryptDatabaseFromBuffer(payload, target), + ), + ); + + expect(DatabaseFileEncryption.isEncryptedDatabaseFile(target)).toBe(true); + + const restored = + await DatabaseFileEncryption.decryptDatabaseToBuffer(target); + const matchesOnePayload = payloads.some((payload) => + payload.equals(restored), + ); + expect(matchesOnePayload).toBe(true); + + // no temporary files left behind + const leftovers = fs + .readdirSync(dataDir) + .filter((name) => name.includes(".tmp-")); + expect(leftovers).toEqual([]); + }); +}); diff --git a/src/backend/tests/database/unreadable-database-guard.test.ts b/src/backend/tests/database/unreadable-database-guard.test.ts new file mode 100644 index 0000000..e97fe0d --- /dev/null +++ b/src/backend/tests/database/unreadable-database-guard.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const tempDirs: string[] = []; +const originalEnv = { ...process.env }; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-db-guard-")); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("unreadable encrypted database guard", () => { + it("refuses to start and leaves the file untouched", async () => { + const dataDir = makeTempDir(); + const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); + // A truncated write: the file exists but its header cannot be parsed. + const damaged = Buffer.from("not-a-valid-encrypted-database-header"); + fs.writeFileSync(encryptedDbPath, damaged); + + process.env.DATA_DIR = dataDir; + process.env.DB_FILE_ENCRYPTION = "true"; + process.env.DATABASE_KEY = "a".repeat(64); + delete process.env.ALLOW_UNREADABLE_DB_RESET; + + const { initializeDatabase } = await import("../../database/db/index.js"); + + await expect(initializeDatabase()).rejects.toThrow( + /not a valid encrypted database/, + ); + + // the damaged file must survive for recovery + expect(fs.readFileSync(encryptedDbPath)).toEqual(damaged); + }); + + it("names the opt-out environment variable in the error", async () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "damaged"); + + process.env.DATA_DIR = dataDir; + process.env.DB_FILE_ENCRYPTION = "true"; + process.env.DATABASE_KEY = "a".repeat(64); + delete process.env.ALLOW_UNREADABLE_DB_RESET; + + const { initializeDatabase, ALLOW_UNREADABLE_DB_RESET_ENV } = + await import("../../database/db/index.js"); + + await expect(initializeDatabase()).rejects.toThrow( + new RegExp(ALLOW_UNREADABLE_DB_RESET_ENV), + ); + }); +}); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index a7c0e51..cb5400e 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -15,6 +15,46 @@ interface EncryptedFileMetadata { dataSize?: number; } +// A millisecond and a pid still collide between processes sharing a data +// directory, such as two containers on one volume. +function uniqueSuffix(): string { + return `${Date.now()}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`; +} + +// Without the fsync, a rename only orders writes within the page cache, so a +// crash can still leave the renamed file empty or truncated. +function writeFileSyncDurable(filePath: string, data: Buffer): void { + const fd = fs.openSync(filePath, "w"); + try { + // writeSync may report a short count + let written = 0; + while (written < data.length) { + written += fs.writeSync(fd, data, written, data.length - written); + } + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function fsyncDirectory(dirPath: string): void { + let fd: number | null = null; + try { + fd = fs.openSync(dirPath, "r"); + fs.fsyncSync(fd); + } catch { + // not supported on every platform or filesystem + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // already closed + } + } + } +} + class DatabaseFileEncryption { private static readonly VERSION = "v2"; private static readonly ALGORITHM = "aes-256-gcm"; @@ -26,7 +66,7 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { - const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + const tmpPath = `${targetPath}.tmp-${uniqueSuffix()}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { @@ -61,8 +101,9 @@ class DatabaseFileEncryption { encrypted, ]); - fs.writeFileSync(tmpPath, finalBuffer); + writeFileSyncDurable(tmpPath, finalBuffer); fs.renameSync(tmpPath, targetPath); + fsyncDirectory(path.dirname(targetPath)); try { if (fs.existsSync(metadataPath)) { @@ -118,7 +159,7 @@ class DatabaseFileEncryption { const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; - const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; + const tmpPath = `${encryptedPath}.tmp-${uniqueSuffix()}`; const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { @@ -155,11 +196,15 @@ class DatabaseFileEncryption { dataSize: encrypted.length, }; - fs.writeFileSync(tmpPath, encrypted); - fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + writeFileSyncDurable(tmpPath, encrypted); + writeFileSyncDurable( + tmpMetadataPath, + Buffer.from(JSON.stringify(metadata, null, 2), "utf8"), + ); fs.renameSync(tmpPath, encryptedPath); fs.renameSync(tmpMetadataPath, metadataPath); + fsyncDirectory(path.dirname(encryptedPath)); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption",