mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-14 02:59:06 +00:00
fix: never replace an unreadable database file with an empty one
This commit is contained in:
@@ -42,7 +42,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -53,42 +56,31 @@ jobs:
|
|||||||
- name: Resolve source revision
|
- name: Resolve source revision
|
||||||
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
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
|
- name: Setup Docker Buildx
|
||||||
uses: useblacksmith/setup-docker-builder@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Determine tags
|
- name: Determine tags
|
||||||
id: tags
|
id: tags
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ inputs.version }}
|
VERSION=${{ inputs.version }}
|
||||||
BUILD_TYPE=${{ inputs.build_type }}
|
BUILD_TYPE=${{ inputs.build_type }}
|
||||||
|
IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
TAGS=()
|
TAGS=()
|
||||||
ALL_TAGS=()
|
ALL_TAGS=()
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||||
TAGS+=("release-$VERSION" "$VERSION" "latest")
|
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
|
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||||
TAGS+=("beta" "beta-$VERSION")
|
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
|
else
|
||||||
TAGS+=("dev-$VERSION")
|
TAGS+=("dev-$VERSION")
|
||||||
for tag in "${TAGS[@]}"; do
|
|
||||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
for tag in "${TAGS[@]}"; do
|
||||||
|
ALL_TAGS+=("$IMAGE:$tag")
|
||||||
|
done
|
||||||
|
|
||||||
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
|
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
@@ -96,23 +88,16 @@ jobs:
|
|||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: lukegus
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Docker Hub (prod and beta only)
|
- name: Build and push image
|
||||||
if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }}
|
uses: docker/build-push-action@v6
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: ${{ !inputs.dry_run }}
|
push: ${{ !inputs.dry_run }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
tags: ${{ env.ALL_TAGS }}
|
tags: ${{ env.ALL_TAGS }}
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ let memoryDatabase: Database.Database;
|
|||||||
let isNewDatabase = false;
|
let isNewDatabase = false;
|
||||||
let sqlite: Database.Database;
|
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 {
|
function getRawSettingValue(key: string): string | null {
|
||||||
const row = sqlite
|
const row = sqlite
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
@@ -67,6 +91,46 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
// expected - sessions table may not exist yet
|
// expected - sessions table may not exist yet
|
||||||
}
|
}
|
||||||
} else {
|
} 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 migration = new DatabaseMigration(dataDir);
|
||||||
const migrationStatus = migration.checkMigrationStatus();
|
const migrationStatus = migration.checkMigrationStatus();
|
||||||
|
|
||||||
@@ -109,6 +173,10 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof UnreadableDatabaseFileError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
databaseLogger.error("Failed to initialize memory database", error, {
|
databaseLogger.error("Failed to initialize memory database", error, {
|
||||||
operation: "db_memory_init_failed",
|
operation: "db_memory_init_failed",
|
||||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
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;
|
if (!memoryDatabase) return;
|
||||||
|
|
||||||
try {
|
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;
|
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 {
|
class DatabaseFileEncryption {
|
||||||
private static readonly VERSION = "v2";
|
private static readonly VERSION = "v2";
|
||||||
private static readonly ALGORITHM = "aes-256-gcm";
|
private static readonly ALGORITHM = "aes-256-gcm";
|
||||||
@@ -26,7 +66,7 @@ class DatabaseFileEncryption {
|
|||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`;
|
const tmpPath = `${targetPath}.tmp-${uniqueSuffix()}`;
|
||||||
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -61,8 +101,9 @@ class DatabaseFileEncryption {
|
|||||||
encrypted,
|
encrypted,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
fs.writeFileSync(tmpPath, finalBuffer);
|
writeFileSyncDurable(tmpPath, finalBuffer);
|
||||||
fs.renameSync(tmpPath, targetPath);
|
fs.renameSync(tmpPath, targetPath);
|
||||||
|
fsyncDirectory(path.dirname(targetPath));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(metadataPath)) {
|
if (fs.existsSync(metadataPath)) {
|
||||||
@@ -118,7 +159,7 @@ class DatabaseFileEncryption {
|
|||||||
const encryptedPath =
|
const encryptedPath =
|
||||||
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
|
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
|
||||||
const metadataPath = `${encryptedPath}${this.METADATA_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}`;
|
const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -155,11 +196,15 @@ class DatabaseFileEncryption {
|
|||||||
dataSize: encrypted.length,
|
dataSize: encrypted.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(tmpPath, encrypted);
|
writeFileSyncDurable(tmpPath, encrypted);
|
||||||
fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2));
|
writeFileSyncDurable(
|
||||||
|
tmpMetadataPath,
|
||||||
|
Buffer.from(JSON.stringify(metadata, null, 2), "utf8"),
|
||||||
|
);
|
||||||
|
|
||||||
fs.renameSync(tmpPath, encryptedPath);
|
fs.renameSync(tmpPath, encryptedPath);
|
||||||
fs.renameSync(tmpMetadataPath, metadataPath);
|
fs.renameSync(tmpMetadataPath, metadataPath);
|
||||||
|
fsyncDirectory(path.dirname(encryptedPath));
|
||||||
|
|
||||||
databaseLogger.info("Database file encrypted successfully", {
|
databaseLogger.info("Database file encrypted successfully", {
|
||||||
operation: "database_file_encryption",
|
operation: "database_file_encryption",
|
||||||
|
|||||||
Reference in New Issue
Block a user