mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 10:49:03 +00:00
fix: never replace an unreadable database file with an empty one
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user