fix(FN-0000): harden sqlite startup validation
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
53
packages/core/src/__tests__/sqlite-validation.test.ts
Normal file
53
packages/core/src/__tests__/sqlite-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
packages/core/src/sqlite-validation.ts
Normal file
38
packages/core/src/sqlite-validation.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user