fix(fusion): prevent nested .fusion roots and safe fn version lookup

This commit is contained in:
gsxdsm
2026-05-03 02:02:25 -07:00
parent 41bb6be0f8
commit 8ba8f63163
14 changed files with 362 additions and 20 deletions

View File

@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
vi.unmock("node:child_process");
vi.unmock("node:fs");
vi.unmock("node:os");
});
function createSpawnMock(options: {
lookupPath: string;
lookupCommand: "which" | "where";
versionStdout?: string;
versionExitCode?: number;
}) {
return vi.fn((command: string, args: string[]) => {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
queueMicrotask(() => {
if (command === options.lookupCommand) {
child.stdout.emit("data", Buffer.from(`${options.lookupPath}\n`));
child.emit("close", 0);
return;
}
if (args.includes("--version")) {
if (options.versionStdout) {
child.stdout.emit("data", Buffer.from(options.versionStdout));
}
child.emit("close", options.versionExitCode ?? 0);
return;
}
child.emit("close", 1);
});
return child;
});
}
async function importWithMocks(options: {
lookupPath: string;
realPath: string;
platform?: "darwin" | "linux" | "win32";
packageJsons?: Record<string, { name: string; version: string }>;
scriptContents?: Record<string, string>;
versionStdout?: string;
versionExitCode?: number;
}) {
const lookupCommand = options.platform === "win32" ? "where" : "which";
const spawnMock = createSpawnMock({
lookupPath: options.lookupPath,
lookupCommand,
versionStdout: options.versionStdout,
versionExitCode: options.versionExitCode,
});
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
platform: () => options.platform ?? "darwin",
};
});
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync: vi.fn(() => options.realPath),
existsSync: vi.fn((path: string) => !!options.packageJsons?.[String(path)]),
readFileSync: vi.fn((path: string) => {
const manifest = options.packageJsons?.[String(path)];
if (manifest) {
return JSON.stringify(manifest);
}
const script = options.scriptContents?.[String(path)];
if (script !== undefined) {
return script;
}
throw new Error(`Unexpected readFileSync(${path})`);
}),
};
});
const mod = await import("../fn-binary.js");
return { mod, spawnMock };
}
describe("detectFnBinary", () => {
it("reads the installed version from the resolved package manifest without executing fn --version", async () => {
const lookupPath = "/opt/homebrew/bin/fn";
const realPath = "/opt/homebrew/lib/node_modules/runfusion.ai/index.js";
const packageJsonPath = "/opt/homebrew/lib/node_modules/runfusion.ai/package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath,
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.13.0",
},
},
});
const result = await mod.detectFnBinary();
expect(result).toMatchObject({
installed: true,
binary: "fn",
path: lookupPath,
version: "0.13.0",
invocation: "fn",
});
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("which", ["fn"], expect.any(Object));
});
it("resolves the installed version from an npm-generated Windows cmd shim without executing fn --version", async () => {
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.cmd";
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
platform: "win32",
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.14.2",
},
},
scriptContents: {
[lookupPath]: "\"%~dp0\\node_modules\\runfusion.ai\\index.js\" %*",
},
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.14.2");
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
});
it("resolves the installed version from an npm-generated Windows PowerShell shim without executing fn --version", async () => {
const lookupPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\fn.ps1";
const packageJsonPath = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\runfusion.ai\\package.json";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
platform: "win32",
packageJsons: {
[packageJsonPath]: {
name: "runfusion.ai",
version: "0.14.3",
},
},
scriptContents: {
[lookupPath]: "& \"$basedir\\node_modules\\runfusion.ai\\index.js\" $args",
},
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.14.3");
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("where", ["fn"], expect.any(Object));
});
it("falls back to fn --version when no package manifest can be derived from the resolved path", async () => {
const lookupPath = "/usr/local/bin/fn";
const { mod, spawnMock } = await importWithMocks({
lookupPath,
realPath: lookupPath,
versionStdout: "fn v0.15.0\n",
});
const result = await mod.detectFnBinary();
expect(result.version).toBe("0.15.0");
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenNthCalledWith(1, "which", ["fn"], expect.any(Object));
expect(spawnMock).toHaveBeenNthCalledWith(2, "fn", ["--version"], expect.any(Object));
});
});

View File

@@ -1664,7 +1664,7 @@ describe("MissionStore", () => {
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
@@ -1674,7 +1674,7 @@ describe("MissionStore", () => {
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1694,7 +1694,7 @@ describe("MissionStore", () => {
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1726,7 +1726,7 @@ describe("MissionStore", () => {
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1747,7 +1747,7 @@ describe("MissionStore", () => {
it("emits feature:linked event", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
@@ -1782,7 +1782,7 @@ describe("MissionStore", () => {
it("throws if slice not found", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
@@ -1792,7 +1792,7 @@ describe("MissionStore", () => {
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1819,7 +1819,7 @@ describe("MissionStore", () => {
it("skips already triaged features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1841,7 +1841,7 @@ describe("MissionStore", () => {
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
@@ -1862,7 +1862,7 @@ describe("MissionStore", () => {
ms: MissionStore;
}> {
const { TaskStore } = await import("../store.js");
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"), { inMemoryDb: true });
const ts = new TaskStore(tmpDir, join(tmpDir, ".fusion-global-settings"), { inMemoryDb: true });
const ms = ts.getMissionStore();
return { ts, ms };
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "../store.js";
import { PluginStore } from "../plugin-store.js";
import { AutomationStore } from "../automation-store.js";
import { RoutineStore } from "../routine-store.js";
describe("project root guards", () => {
const fusionDir = join(tmpdir(), "fusion-root-guard", ".fusion");
it.each([
["TaskStore", () => new TaskStore(fusionDir, undefined, { inMemoryDb: true })],
["PluginStore", () => new PluginStore(fusionDir, { inMemoryDb: true })],
["AutomationStore", () => new AutomationStore(fusionDir, { inMemoryDb: true })],
["RoutineStore", () => new RoutineStore(fusionDir, { inMemoryDb: true })],
])("rejects a .fusion directory for %s", (_label, createStore) => {
expect(createStore).toThrow(/expected a project root, got a \.fusion directory/i);
});
});