fix: never replace an unreadable database file with an empty one

This commit is contained in:
2026-07-23 01:48:12 +07:00
parent cf3e2cb499
commit 9d91c31027
5 changed files with 265 additions and 36 deletions
+15 -30
View File
@@ -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
+79 -1
View File
@@ -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<void> {
// 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<void> {
}
}
} 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<void> {
// 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<void> = Promise.resolve();
function saveMemoryDatabaseToFile(): Promise<void> {
saveQueue = saveQueue.then(() => writeMemoryDatabaseToDisk());
return saveQueue;
}
async function writeMemoryDatabaseToDisk(): Promise<void> {
if (!memoryDatabase) return;
try {
@@ -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([]);
});
});
@@ -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),
);
});
});
+50 -5
View File
@@ -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<string> {
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",