fix(FN-0000): harden sqlite startup validation
This commit is contained in:
@@ -117,6 +117,16 @@ describe("project-context", () => {
|
||||
expect(found?.path).toBe(resolve(projectPath));
|
||||
expect(found?.name).toBe("legacy-project");
|
||||
});
|
||||
|
||||
it("should ignore invalid fusion.db files in the cwd", async () => {
|
||||
const projectPath = join(tempDir, "invalid-project");
|
||||
mkdirSync(join(projectPath, ".fusion"), { recursive: true });
|
||||
writeFileSync(join(projectPath, ".fusion", "fusion.db"), "SQLite format 3\x00");
|
||||
|
||||
const found = await detectProjectFromCwd(projectPath, central);
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProjectLine", () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
|
||||
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs module
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
@@ -23,6 +27,8 @@ vi.mock("@fusion/core", async () => {
|
||||
getProjectHealth = vi.fn().mockResolvedValue(undefined);
|
||||
isInitialized = vi.fn().mockReturnValue(true);
|
||||
},
|
||||
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
|
||||
mockIsValidSqliteDatabaseFile(...args),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
@@ -69,6 +75,7 @@ describe("Project Resolver", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetProjectResolution();
|
||||
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
|
||||
vi.mocked(TaskStore).mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
@@ -81,49 +88,38 @@ describe("Project Resolver", () => {
|
||||
|
||||
describe("findKbDir", () => {
|
||||
it("should find .fusion directory in current path", () => {
|
||||
vi.mocked(existsSync)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
|
||||
|
||||
const result = findKbDir("/project");
|
||||
expect(result).toBe("/project");
|
||||
});
|
||||
|
||||
it("should walk up parent directories to find .fusion", () => {
|
||||
vi.mocked(existsSync)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/a/b/.fusion/fusion.db");
|
||||
|
||||
const result = findKbDir("/a/b/c");
|
||||
expect(result).toBe("/a/b");
|
||||
});
|
||||
|
||||
it("should return null if no .fusion found", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
const result = findKbDir("/some/path");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if .fusion is not a directory", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any);
|
||||
it("should return null if fusion.db is not a valid SQLite database", () => {
|
||||
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
|
||||
const result = findKbDir("/project");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKbProject", () => {
|
||||
it("should return true if .fusion directory exists", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
it("should return true if fusion.db is a valid SQLite database", () => {
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
|
||||
expect(isKbProject("/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if .fusion directory does not exist", () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
it("should return false if fusion.db is invalid or missing", () => {
|
||||
expect(isKbProject("/project")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -200,8 +196,7 @@ describe("Project Resolver", () => {
|
||||
});
|
||||
|
||||
it("should throw NOT_REGISTERED if .fusion exists but project not registered", async () => {
|
||||
vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/unregistered/.fusion/fusion.db");
|
||||
|
||||
const core = await getCentralCore();
|
||||
core.listProjects.mockResolvedValue([]);
|
||||
@@ -213,8 +208,6 @@ describe("Project Resolver", () => {
|
||||
});
|
||||
|
||||
it("should throw NO_PROJECTS when no projects registered and no .fusion found", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const core = await getCentralCore();
|
||||
core.listProjects.mockResolvedValue([]);
|
||||
|
||||
@@ -283,11 +276,8 @@ describe("Project Resolver", () => {
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path) => {
|
||||
const p = String(path);
|
||||
return p === "/workspace/cwd-match/.fusion" || p === "/workspace/cwd-match";
|
||||
});
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/cwd-match/.fusion/fusion.db");
|
||||
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/cwd-match");
|
||||
|
||||
const core = await getCentralCore();
|
||||
core.listProjects.mockResolvedValue([mockProject]);
|
||||
@@ -329,8 +319,8 @@ describe("Project Resolver", () => {
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion");
|
||||
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion/fusion.db");
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const core = await getCentralCore();
|
||||
core.listProjects.mockResolvedValue([match]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../init.js";
|
||||
@@ -17,20 +17,29 @@ const mockCentralClose = vi.fn();
|
||||
const mockGetProjectByPath = vi.fn();
|
||||
const mockRegisterProject = vi.fn();
|
||||
const mockUpdateProject = vi.fn().mockResolvedValue({});
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockCentralInit,
|
||||
close: mockCentralClose,
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
registerProject: mockRegisterProject,
|
||||
updateProject: mockUpdateProject,
|
||||
})),
|
||||
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
|
||||
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
|
||||
resolveGlobalDir: vi.fn(),
|
||||
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
|
||||
mockIsValidSqliteDatabaseFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockCentralInit,
|
||||
close: mockCentralClose,
|
||||
getProjectByPath: mockGetProjectByPath,
|
||||
registerProject: mockRegisterProject,
|
||||
updateProject: mockUpdateProject,
|
||||
})),
|
||||
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
|
||||
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
|
||||
resolveGlobalDir: vi.fn(),
|
||||
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
|
||||
mockIsValidSqliteDatabaseFile(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
@@ -62,6 +71,13 @@ describe("init command", () => {
|
||||
path: tempProjectDir,
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => {
|
||||
if (!existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return readFileSync(dbPath).length === 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,6 +116,18 @@ describe("init command", () => {
|
||||
await runInit({ path: tempProjectDir });
|
||||
|
||||
expect(existsSync(dbPath)).toBe(true);
|
||||
expect(statSync(dbPath).size).toBe(0);
|
||||
});
|
||||
|
||||
it("should reject existing invalid fusion.db files", async () => {
|
||||
const fusionDir = join(tempProjectDir, ".fusion");
|
||||
const dbPath = join(fusionDir, "fusion.db");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
writeFileSync(dbPath, "not a sqlite database");
|
||||
|
||||
await expect(runInit({ path: tempProjectDir })).rejects.toThrow(
|
||||
`Existing database at ${dbPath} is not a valid SQLite database.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be idempotent - report already initialized", async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { join, resolve, basename } from "node:path";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
const execAsync = promisify(exec);
|
||||
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
|
||||
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable, isValidSqliteDatabaseFile } from "@fusion/core";
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
import { isGitRepo } from "./git.js";
|
||||
import {
|
||||
@@ -41,9 +41,11 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
const cwd = options.path ? resolve(options.path) : process.cwd();
|
||||
const fusionDir = join(cwd, ".fusion");
|
||||
const dbPath = join(fusionDir, "fusion.db");
|
||||
const hasDbPath = existsSync(dbPath);
|
||||
const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath);
|
||||
|
||||
// Check if already initialized
|
||||
if (existsSync(fusionDir) && existsSync(dbPath)) {
|
||||
if (existsSync(fusionDir) && hasDbPath && hasValidDb) {
|
||||
// Check if registered in central DB
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
@@ -69,6 +71,13 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(fusionDir) && hasDbPath && !hasValidDb) {
|
||||
throw new Error(
|
||||
`Existing database at ${dbPath} is not a valid SQLite database. ` +
|
||||
"Restore it from .fusion/backups or move it aside before re-running fn init.",
|
||||
);
|
||||
}
|
||||
|
||||
// Get or generate project name
|
||||
const projectName = options.name ?? await detectProjectName(cwd);
|
||||
|
||||
@@ -95,12 +104,8 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
|
||||
// Create fusion.db (empty SQLite file)
|
||||
if (!existsSync(dbPath)) {
|
||||
// SQLite database header for an empty database
|
||||
const sqliteHeader = Buffer.from([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
|
||||
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
|
||||
]);
|
||||
writeFileSync(dbPath, sqliteHeader);
|
||||
// A zero-byte bootstrap file is a valid SQLite starting point.
|
||||
writeFileSync(dbPath, "");
|
||||
console.log(` ✓ Created fusion.db`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
* for operating on tasks across multiple registered projects.
|
||||
*/
|
||||
|
||||
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core";
|
||||
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore, isValidSqliteDatabaseFile } from "@fusion/core";
|
||||
import { resolve, dirname, basename } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/** Project context for CLI operations */
|
||||
export interface ProjectContext {
|
||||
@@ -187,7 +186,7 @@ export async function detectProjectFromCwd(
|
||||
while (true) {
|
||||
// Check for fn database
|
||||
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
|
||||
if (existsSync(kbPath)) {
|
||||
if (isValidSqliteDatabaseFile(kbPath)) {
|
||||
// Found a fn project - check if it's registered
|
||||
const project = await central.getProjectByPath(currentDir);
|
||||
if (project) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { basename, dirname, resolve, normalize } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core";
|
||||
import { CentralCore, isValidSqliteDatabaseFile, type RegisteredProject, type TaskStore } from "@fusion/core";
|
||||
import { ProjectManager } from "@fusion/engine";
|
||||
|
||||
// Singleton instances for reuse across commands
|
||||
@@ -106,8 +106,8 @@ export function findKbDir(startPath: string): string | null {
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const kbPath = resolve(current, ".fusion");
|
||||
if (existsSync(kbPath) && statSync(kbPath).isDirectory()) {
|
||||
const dbPath = resolve(current, ".fusion", "fusion.db");
|
||||
if (isValidSqliteDatabaseFile(dbPath)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -454,8 +454,7 @@ export async function isProjectNameTaken(
|
||||
* Validate that a path contains an initialized fn project (.fusion/ directory exists).
|
||||
*/
|
||||
export function isKbProject(path: string): boolean {
|
||||
const kbPath = resolve(path, ".fusion");
|
||||
return existsSync(kbPath) && statSync(kbPath).isDirectory();
|
||||
return isValidSqliteDatabaseFile(resolve(path, ".fusion", "fusion.db"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user