fix(FN-0000): harden sqlite startup validation

This commit is contained in:
gsxdsm
2026-04-29 07:58:58 -07:00
parent 858e24468f
commit 3202e578c2
19 changed files with 648 additions and 292 deletions

View File

@@ -170,6 +170,17 @@ describe("MigrationOrchestrator", () => {
expect(detected).toHaveLength(0);
});
it("should ignore header-only fusion.db files that are not real SQLite databases", async () => {
const projectDir = join(tempDir, "invalid-project");
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
writeFileSync(join(projectDir, ".fusion", "fusion.db"), "SQLite format 3\x00");
const detected = await orchestrator.detectExistingProjects(projectDir);
expect(detected).toHaveLength(0);
});
});
describe("autoRegisterProjects", () => {

View File

@@ -3,7 +3,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tempWorkspace, useIsolatedCwd } from "@fusion/test-utils";
@@ -20,10 +20,48 @@ import { CentralCore } from "../central-core.js";
function createFakeKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
mkdirSync(kbDir, { recursive: true });
// Create empty fusion.db file (SQLite needs actual format, but for detection an empty file works)
// A zero-byte file is a valid SQLite bootstrap database.
writeFileSync(join(kbDir, "fusion.db"), "");
}
function createInvalidKbProject(dir: string): void {
const kbDir = join(dir, ".fusion");
mkdirSync(kbDir, { recursive: true });
writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00");
}
async function withDefaultFirstRunDetector<T>(
homeDir: string,
fn: (detector: FirstRunDetector) => Promise<T> | T,
): Promise<T> {
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedVitest = process.env.VITEST;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.VITEST;
try {
return await fn(new FirstRunDetector());
} finally {
if (savedHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = savedHome;
}
if (savedUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = savedUserProfile;
}
if (savedVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = savedVitest;
}
}
}
// Helper to create a fake git remote
async function initGitRepo(dir: string, remoteUrl?: string): Promise<void> {
const { execFile } = await import("node:child_process");
@@ -222,6 +260,16 @@ describe("FirstRunDetector", () => {
expect(projects).toHaveLength(0);
});
it("should ignore invalid fusion.db files when scanning for local projects", async () => {
const tempProjectDir = tempWorkspace("kb-invalid-detect-");
createInvalidKbProject(tempProjectDir);
const detector = new FirstRunDetector(tempGlobalDir);
const projects = await detector.detectExistingProjects(tempProjectDir);
expect(projects).toHaveLength(0);
});
});
describe("generateProjectName", () => {
@@ -260,6 +308,18 @@ describe("FirstRunDetector", () => {
const detector = new FirstRunDetector(tempGlobalDir);
expect(detector.getCentralDbPath()).toBe(join(tempGlobalDir, "fusion-central.db"));
});
it("should default to ~/.fusion when no explicit global dir is provided", async () => {
const homeDir = tempWorkspace("kb-default-global-dir-");
try {
await withDefaultFirstRunDetector(homeDir, (detector) => {
expect(detector.getCentralDbPath()).toBe(join(homeDir, ".fusion", "fusion-central.db"));
});
} finally {
rmSync(homeDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isValidSqliteDatabaseFile } from "../sqlite-validation.js";
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "kb-sqlite-validation-"));
}
describe("isValidSqliteDatabaseFile", () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns false when the file does not exist", () => {
const dir = makeTempDir();
dirs.push(dir);
expect(isValidSqliteDatabaseFile(join(dir, "missing.db"))).toBe(false);
});
it("returns true for a zero-byte bootstrap database", () => {
const dir = makeTempDir();
dirs.push(dir);
const dbPath = join(dir, "fusion.db");
writeFileSync(dbPath, "");
expect(isValidSqliteDatabaseFile(dbPath)).toBe(true);
});
it("returns false for plain text files", () => {
const dir = makeTempDir();
dirs.push(dir);
const dbPath = join(dir, "fusion.db");
writeFileSync(dbPath, "not a sqlite database");
expect(isValidSqliteDatabaseFile(dbPath)).toBe(false);
});
it("returns false for a header-only file", () => {
const dir = makeTempDir();
dirs.push(dir);
const dbPath = join(dir, "fusion.db");
writeFileSync(dbPath, "SQLite format 3\x00");
expect(isValidSqliteDatabaseFile(dbPath)).toBe(false);
});
});

View File

@@ -220,7 +220,12 @@ export class CentralDatabase {
mkdirSync(this.globalDir, { recursive: true });
}
this.db = new DatabaseSync(this.dbPath);
try {
this.db = new DatabaseSync(this.dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to open Fusion central database at ${this.dbPath}: ${message}`);
}
// Enable WAL mode for concurrent reader/writer access
this.db.exec("PRAGMA journal_mode = WAL");

View File

@@ -677,7 +677,12 @@ export class Database {
mkdirSync(fusionDir, { recursive: true });
}
this.db = new DatabaseSync(this.dbPath);
try {
this.db = new DatabaseSync(this.dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to open Fusion database at ${this.dbPath}: ${message}`);
}
// WAL is meaningless for `:memory:` connections — SQLite ignores it
// and there's no other writer to coordinate with — so we skip it. The

View File

@@ -50,6 +50,7 @@ export type { Statement } from "./db.js";
export { ArchiveDatabase } from "./archive-db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
export { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";

View File

@@ -31,6 +31,7 @@ import type {
RegisteredProject,
} from "./types.js";
import type { CentralCore } from "./central-core.js";
import { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
// ── Constants ──────────────────────────────────────────────────────────────
@@ -200,38 +201,10 @@ export class MigrationOrchestrator {
/**
* Check if a directory contains a valid kb project.
* Validates that .fusion/fusion.db (or legacy .fusion/fusion.db) exists and is a file.
* Validates that .fusion/fusion.db is an openable SQLite database.
*/
private isKbProject(dir: string): boolean {
// Check current layout: .fusion/fusion.db
const fusionPath = join(dir, ".fusion");
if (existsSync(fusionPath)) {
const fusionDb = join(fusionPath, "fusion.db");
if (existsSync(fusionDb)) {
try {
const stats = statSync(fusionDb);
if (stats.isFile()) return true;
} catch { /* fall through */ }
}
}
// Fall back to legacy layout: .fusion/fusion.db
const kbPath = join(dir, ".fusion");
if (!existsSync(kbPath)) {
return false;
}
const dbPath = join(kbPath, "fusion.db");
if (!existsSync(dbPath)) {
return false;
}
try {
const stats = statSync(dbPath);
return stats.isFile();
} catch {
return false;
}
return isValidSqliteDatabaseFile(join(dir, ".fusion", "fusion.db"));
}
/**
@@ -481,4 +454,4 @@ export class MigrationOrchestrator {
*/
export function createMigrationOrchestrator(centralCore: CentralCore): MigrationOrchestrator {
return new MigrationOrchestrator(centralCore);
}
}

View File

@@ -10,18 +10,20 @@
* @module migration
*/
import { existsSync, statSync } from "node:fs";
import { existsSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js";
import { CentralCore as CentralCoreClass } from "./central-core.js";
import { resolveGlobalDir } from "./global-settings.js";
import { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
/**
* Check whether `<dir>/<folderName>/<dbName>` exists as a non-empty regular file.
* Check whether `<dir>/<folderName>/<dbName>` exists as an openable SQLite file.
* Used to decide if a directory contains a current (.fusion/fusion.db) project database.
*/
function hasProjectDbFile(dir: string, folderName: string, dbName: string): boolean {
@@ -29,14 +31,7 @@ function hasProjectDbFile(dir: string, folderName: string, dbName: string): bool
const dbPath = join(projectDir, dbName);
if (!existsSync(projectDir)) return false;
if (!existsSync(dbPath)) return false;
try {
const stat = statSync(dbPath);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
return isValidSqliteDatabaseFile(dbPath);
}
// ── Types ────────────────────────────────────────────────────────────
@@ -303,7 +298,7 @@ export class FirstRunDetector {
}
private getDefaultGlobalDir(): string {
return join(getHomeDir(), ".pi", "kb");
return resolveGlobalDir();
}
}

View File

@@ -0,0 +1,38 @@
import { existsSync, statSync } from "node:fs";
import { DatabaseSync } from "./sqlite-adapter.js";
/**
* Validate that a path points to a SQLite database file that can be opened.
*
* Zero-byte files are treated as valid bootstrap databases because SQLite
* upgrades them in place on first open. Non-existent paths and unreadable or
* malformed files return false.
*/
export function isValidSqliteDatabaseFile(dbPath: string): boolean {
if (!existsSync(dbPath)) {
return false;
}
try {
if (!statSync(dbPath).isFile()) {
return false;
}
} catch {
return false;
}
let db: DatabaseSync | null = null;
try {
db = new DatabaseSync(dbPath);
db.prepare("PRAGMA schema_version").get();
return true;
} catch {
return false;
} finally {
try {
db?.close();
} catch {
// Best-effort close only.
}
}
}