From 97fb59a2826792d893b5e0659cc6b8ea62efe4d3 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 | 62 ++++---------- env.catalog.json | 6 ++ src/backend/database/db/index.ts | 82 ++++++++++++++++++- .../concurrent-save-serialization.test.ts | 56 +++++++++++++ .../unreadable-database-guard.test.ts | 65 +++++++++++++++ src/backend/utils/database-file-encryption.ts | 55 +++++++++++-- 6 files changed, 272 insertions(+), 54 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 db4cc7c6..1ffaa66b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -44,8 +44,11 @@ jobs: # One image per plugin profile (docker/bundled-plugins.json). standard keeps # the plain tags; minimal and full add a suffix. build: - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + packages: write strategy: fail-fast: false matrix: @@ -67,21 +70,15 @@ jobs: if: ${{ inputs.build_type == 'Beta' }} run: node scripts/sync-version.cjs --version "${{ inputs.version }}" - - 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@v2 - with: - cache-key: termix-ssh/termix + 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=() @@ -92,19 +89,15 @@ jobs: fi if [ "$BUILD_TYPE" = "Production" ]; then - TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX") + TAGS+=("release-$VERSION$SUFFIX" "$VERSION$SUFFIX" "latest$SUFFIX") elif [ "$BUILD_TYPE" = "Beta" ]; then - TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX") + TAGS+=("beta$SUFFIX" "beta-$VERSION$SUFFIX") else TAGS+=("dev-$VERSION$SUFFIX") fi for tag in "${TAGS[@]}"; do - ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") - ALL_TAGS+=("ghcr.io/termix-ssh/termix:$tag") - if [ "$BUILD_TYPE" != "Development" ]; then - ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag") - fi + ALL_TAGS+=("$IMAGE:$tag") done echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV @@ -115,23 +108,16 @@ jobs: uses: docker/login-action@v4 with: registry: ghcr.io - username: lukegus - password: ${{ secrets.TERMIX_PAT }} + 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_BUGATTIGUY527_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 @@ -142,26 +128,6 @@ jobs: org.opencontainers.image.created=${{ env.BUILD_CREATED }} outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }} - - name: Login to Docker Hub as termixssh - id: termix_hub - if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }} - env: - TOKEN: ${{ secrets.DOCKERHUB_TERMIXSSH_TOKEN }} - run: | - if [ -z "$TOKEN" ]; then - echo "DOCKERHUB_TERMIXSSH_TOKEN is not set, skipping docker.io/termixssh" - exit 0 - fi - echo "$TOKEN" | docker login docker.io --username termixssh --password-stdin - echo "ready=true" >> "$GITHUB_OUTPUT" - - - name: Copy image to docker.io/termixssh - if: ${{ steps.termix_hub.outputs.ready == 'true' }} - run: | - for tag in $TAGS; do - docker buildx imagetools create --tag "docker.io/termixssh/termix:$tag" "ghcr.io/termix-ssh/termix:$tag" - done - - name: Cleanup Docker if: always() run: | diff --git a/env.catalog.json b/env.catalog.json index 5c52fb04..2abcaa09 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -159,6 +159,12 @@ "default": "true", "description": "Encrypt the SQLite file at rest. Set false only if something else already encrypts the disk." }, + { + "name": "ALLOW_UNREADABLE_DB_RESET", + "group": "database", + "default": "false", + "description": "Discard an unrecognized encrypted SQLite file and start fresh. Keep a recovery copy first. Leave unset to refuse startup and preserve the existing file." + }, { "name": "DB_FILE_KEY", "group": "database", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index c70cef2a..811c84e0 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -51,6 +51,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 = ?") @@ -93,6 +117,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(); @@ -136,6 +200,12 @@ async function initializeDatabaseAsync(): Promise { } } } catch (error) { + // Already a precise diagnosis of an intact-but-unusable file. The + // generic decryption error below would only obscure it. + if (error instanceof UnreadableDatabaseFileError) { + throw 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; @@ -1808,7 +1878,17 @@ function vacuumIfFreelistBloated(): void { } } -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 00000000..f5e15ecd --- /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 00000000..e97fe0d3 --- /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 e6ebdc6b..a6c94b63 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -16,6 +16,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 + } + } + } +} + const ENCRYPT_CHUNK_BYTES = 4 * 1024 * 1024; class DatabaseFileEncryption { @@ -29,7 +69,7 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { - const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`; + const tmpPath = `${targetPath}.tmp-${uniqueSuffix()}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { @@ -78,8 +118,9 @@ class DatabaseFileEncryption { encrypted, ]); - await fs.promises.writeFile(tmpPath, finalBuffer); + await fs.promises.writeFile(tmpPath, finalBuffer, { flush: true }); await fs.promises.rename(tmpPath, targetPath); + fsyncDirectory(path.dirname(targetPath)); try { if (fs.existsSync(metadataPath)) { @@ -129,7 +170,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 { @@ -166,11 +207,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",