fix(fusion): prevent nested .fusion roots and safe fn version lookup
This commit is contained in:
195
packages/core/src/__tests__/fn-binary-probe.test.ts
Normal file
195
packages/core/src/__tests__/fn-binary-probe.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
20
packages/core/src/__tests__/project-root-guard.test.ts
Normal file
20
packages/core/src/__tests__/project-root-guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user