* fix(javascript): preserve literal percent runtime paths * fix(javascript): ignore resolve prefixes before the final anchor * fix(electron): retain application roots for loadFile declarations * fix(electron): resolve loadFile from enclosing application package * fix(electron): retain outer application root across package scopes * fix(electron): derive application ownership from inventoried packages * fix(electron): retain resolved package entrypoint ownership * fix(electron): preserve competing application ownership * fix(electron): retain invalid and omitted runtime ownership * fix(electron): preserve omitted module ownership and runtime paths * fix(electron): preserve proven roots before confining page paths * fix(electron): retain possible owners behind missing imports * fix(electron): distinguish absent imports and owned page candidates * fix(electron): keep unavailable JSON out of owner gaps * fix(electron): retain exact loader formats and default entry status * fix(electron): retain unavailable entrypoint ownership candidates * test(ghidra): preserve Windows Node probe prerequisites * test(ghidra): assert cancellation during profile shutdown * fix(electron): separate examined imports from frontend ownership * fix(electron): retain unavailable directory entrypoint owners * test(jeb): retain Linux ownership prerequisite in isolation * fix(javascript): retain unchecked loader ownership and literal main paths * fix(javascript): preserve runtime paths and call-site module aliases * fix(javascript): bound module alias traversal and preserve projections * fix(javascript): retain host module and nested artifact path boundaries * fix(javascript): preserve ESM and archive module loader provenance * test(javascript): distinguish unmapped containers from missing exports * fix: retain default module aliases and Electron builtin imports * fix: preserve host file URL and inward ASAR ownership * fix: preserve CommonJS file URL classification in CI fixtures * fix: retain bound module loaders and Windows entrypoint uncertainty * fix: retain ASAR uncertainty and invalidate changed Electron app roots * fix(javascript): retain ownership uncertainty across loader boundaries * fix(javascript): preserve loader sources and bound alias queries * fix(javascript): retain destructured loader sources * fix(javascript): preserve loader uncertainty at resolution boundaries * fix(javascript): preserve callable and package launch ownership * fix(javascript): preserve captured values across member reads * test(process): assert tagged filesystem cancellation failures * fix(javascript): preserve inventory bases and invocation read timing * fix(javascript): preserve ownership across indirect and unread loads * test(javascript): order fixture directory creation on Windows * test(javascript): share ordered filesystem fixture writer * fix(javascript): unify loader provenance and package validation * fix(javascript): preserve captured Node require loader values * fix(javascript): preserve loader invocation authority * fix(javascript): verify generic loader invocation authority * fix(javascript): preserve conditional loader method alternatives * fix(javascript): require proven factories for source association * fix(javascript): honor replaced Electron app path setters * fix(javascript): prove path and assignment source ownership * fix(javascript): retain native delegate eligibility through shared writes * fix(process): settle stale baseline errors after confirmed absence * fix(javascript): preserve assignment and path boundary evidence * fix(javascript): prove source-directory resolve anchors * fix: preserve application path provenance and portable schemas * fix(javascript): retain CommonJS exported setter effects * fix: isolate Linux identity observations and retain native module exports * fix(javascript): preserve published app and helper provenance * fix(javascript): normalize typed stores and native path fixtures
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
const race = vi.hoisted(() => ({
|
|
mode: "" as
|
|
| "readlink_missing"
|
|
| "lstat_missing"
|
|
| "lstat_identity_changed"
|
|
| "directory_missing"
|
|
| "readlink_eio"
|
|
| "readlink_eacces"
|
|
| "readlink_regular_replacement"
|
|
| "cancel_readlink"
|
|
| "cancel_readlink_missing"
|
|
| "cancel_directory_close"
|
|
| "",
|
|
target: "",
|
|
opened: false,
|
|
closeCompleted: false,
|
|
controller: undefined as AbortController | undefined,
|
|
reason: undefined as Error | undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...original,
|
|
readlink: async (...args: Parameters<typeof original.readlink>) => {
|
|
const path = args[0];
|
|
if (path === race.target && race.mode === "readlink_missing") {
|
|
await original.rm(path);
|
|
return original.readlink(...args);
|
|
}
|
|
if (path === race.target && race.mode === "readlink_eio")
|
|
throw race.reason;
|
|
if (path === race.target && race.mode === "readlink_eacces")
|
|
throw Object.assign(new Error("injected denied readlink"), {
|
|
code: "EACCES",
|
|
});
|
|
if (
|
|
path === race.target &&
|
|
race.mode === "readlink_regular_replacement"
|
|
) {
|
|
await original.rm(path);
|
|
await original.writeFile(path, "replacement regular file");
|
|
}
|
|
if (path === race.target && race.mode === "cancel_readlink_missing") {
|
|
await original.rm(path);
|
|
try {
|
|
return await original.readlink(...args);
|
|
} catch (cause: unknown) {
|
|
race.controller?.abort(race.reason);
|
|
throw cause;
|
|
}
|
|
}
|
|
const target = await original.readlink(...args);
|
|
if (path === race.target) {
|
|
if (race.mode === "lstat_missing") {
|
|
await original.rm(path);
|
|
} else if (race.mode === "lstat_identity_changed") {
|
|
const replacement = `${path}.replacement`;
|
|
await original.symlink("replacement-target", replacement);
|
|
await original.rm(path);
|
|
await original.rename(replacement, path);
|
|
} else if (race.mode === "cancel_readlink") {
|
|
race.controller?.abort(race.reason);
|
|
}
|
|
}
|
|
return target;
|
|
},
|
|
lstat: async (...args: Parameters<typeof original.lstat>) => {
|
|
const path = args[0];
|
|
if (
|
|
path === race.target &&
|
|
race.mode === "directory_missing" &&
|
|
race.opened
|
|
) {
|
|
race.mode = "";
|
|
race.opened = false;
|
|
await original.rm(path, { recursive: true });
|
|
return original.lstat(...args);
|
|
}
|
|
return original.lstat(...args);
|
|
},
|
|
opendir: async (...args: Parameters<typeof original.opendir>) => {
|
|
const directory = await original.opendir(...args);
|
|
if (args[0] === race.target && race.mode === "directory_missing")
|
|
race.opened = true;
|
|
if (args[0] === race.target && race.mode === "cancel_directory_close") {
|
|
const close = directory.close.bind(directory);
|
|
vi.spyOn(directory, "close").mockImplementation(async () => {
|
|
await close();
|
|
race.closeCompleted = true;
|
|
race.controller?.abort(race.reason);
|
|
});
|
|
}
|
|
// The selected directory's next lstat is snapshotRoots' post-enumeration
|
|
// identity check. The deletion itself happens in that lstat wrapper.
|
|
return directory;
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
parseProcessScenario,
|
|
type ProcessScenario,
|
|
} from "../../src/domain/process/processScenario.js";
|
|
import { classifyFilesystemEffects } from "../../src/process/capture/ProcessFilesystemEffects.js";
|
|
import {
|
|
snapshotRoots,
|
|
ProcessCaptureSnapshotFailure,
|
|
} from "../../src/process/capture/FilesystemSnapshot.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
|
|
const resetRace = () => {
|
|
race.mode = "";
|
|
race.target = "";
|
|
race.opened = false;
|
|
race.closeCompleted = false;
|
|
race.controller = undefined;
|
|
race.reason = undefined;
|
|
};
|
|
|
|
afterEach(() => {
|
|
resetRace();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const expectCancelledSnapshot = async (
|
|
scenario: ProcessScenario,
|
|
controller: AbortController,
|
|
): Promise<void> => {
|
|
const cause = await snapshotRoots(scenario, controller.signal).catch(
|
|
(failure: unknown) => failure,
|
|
);
|
|
expect(controller.signal.aborted).toBe(true);
|
|
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
const failure = cause as ProcessCaptureSnapshotFailure;
|
|
expect(failure.cause).toBe(controller.signal.reason);
|
|
expect(failure.userCategory).toBe("cancelled");
|
|
expect(failure.snapshot).toMatchObject({
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_truncated: true },
|
|
});
|
|
expect(failure.snapshot?.files).toContainEqual(
|
|
expect.objectContaining({ path: "root_0:.", type: "directory" }),
|
|
);
|
|
expect(failure.cleanupLeases).toEqual([]);
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
};
|
|
|
|
const withTemporaryRoot = async <Value>(
|
|
prefix: string,
|
|
run: (root: string) => Promise<Value>,
|
|
): Promise<Value> => {
|
|
const root = await createTestTempDirectory(prefix);
|
|
try {
|
|
return await run(root);
|
|
} finally {
|
|
resetRace();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
const entryUnavailable = async (
|
|
mode:
|
|
| "readlink_missing"
|
|
| "lstat_missing"
|
|
| "readlink_eacces"
|
|
| "readlink_regular_replacement",
|
|
systemCode: string,
|
|
) => {
|
|
await withTemporaryRoot("rea-snapshot-entry-race-", async (root) => {
|
|
const sibling = join(root, "a-sibling");
|
|
const link = join(root, "z-link");
|
|
await writeFile(sibling, "retain this sibling");
|
|
await symlink("missing-target", link);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
race.mode = mode;
|
|
race.target = link;
|
|
const after = await snapshotRoots(scenario);
|
|
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
|
|
expect(after.files.map(({ path }) => path)).not.toContain("root_0:z-link");
|
|
expect(after.completeRoots).toEqual([]);
|
|
expect(after.coverage).toMatchObject({
|
|
enumeration_truncated: true,
|
|
enumeration_reasons: ["entry_unavailable"],
|
|
enumeration_omissions: [
|
|
{
|
|
path: "root_0:z-link",
|
|
reason: "entry_unavailable",
|
|
system_code: systemCode,
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:z-link",
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
reason: expect.stringContaining("absence remains unknown"),
|
|
});
|
|
});
|
|
};
|
|
|
|
it.skipIf(process.platform === "win32").each([
|
|
{ name: "readlink ENOENT", mode: "readlink_missing", systemCode: "ENOENT" },
|
|
{
|
|
name: "follow-up lstat ENOENT",
|
|
mode: "lstat_missing",
|
|
systemCode: "ENOENT",
|
|
},
|
|
{ name: "readlink EACCES", mode: "readlink_eacces", systemCode: "EACCES" },
|
|
{
|
|
name: "regular-file replacement (readlink EINVAL)",
|
|
mode: "readlink_regular_replacement",
|
|
systemCode: "EINVAL",
|
|
},
|
|
] as const)(
|
|
"retains sibling observations after $name",
|
|
async ({ mode, systemCode }) => {
|
|
await entryUnavailable(mode, systemCode);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"records known symlink identity replacement separately from unavailable entries",
|
|
async () =>
|
|
withTemporaryRoot("rea-snapshot-link-identity-", async (root) => {
|
|
const sibling = join(root, "a-sibling");
|
|
const link = join(root, "z-link");
|
|
await writeFile(sibling, "retain this sibling");
|
|
await symlink("original-target", link);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
race.mode = "lstat_identity_changed";
|
|
race.target = link;
|
|
const after = await snapshotRoots(scenario);
|
|
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
|
|
expect(after.files.map(({ path }) => path)).not.toContain(
|
|
"root_0:z-link",
|
|
);
|
|
expect(after.completeRoots).toEqual([]);
|
|
expect(after.coverage.enumeration_reasons).toEqual(["identity_changed"]);
|
|
expect(after.coverage.enumeration_omissions).toContainEqual({
|
|
path: "root_0:z-link",
|
|
reason: "identity_changed",
|
|
system_code: null,
|
|
});
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:z-link",
|
|
)?.status,
|
|
).toBe("unknown");
|
|
}),
|
|
);
|
|
|
|
it("retains siblings and marks child absence unknown when directory revalidation sees ENOENT", async () =>
|
|
withTemporaryRoot("rea-snapshot-directory-race-", async (root) => {
|
|
const sibling = join(root, "a-sibling");
|
|
const directory = join(root, "z-directory");
|
|
await writeFile(sibling, "retain this sibling");
|
|
await mkdir(directory);
|
|
await writeFile(join(directory, "child"), "child observed before race");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
race.mode = "directory_missing";
|
|
race.target = directory;
|
|
const after = await snapshotRoots(scenario);
|
|
expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling");
|
|
expect(after.files.map(({ path }) => path)).toContain("root_0:z-directory");
|
|
expect(after.files.map(({ path }) => path)).not.toContain(
|
|
join("root_0:z-directory", "child"),
|
|
);
|
|
expect(after.completeRoots).toEqual([]);
|
|
expect(after.coverage.enumeration_reasons).toEqual(["entry_unavailable"]);
|
|
expect(after.coverage.enumeration_omissions).toContainEqual({
|
|
path: "root_0:z-directory",
|
|
reason: "entry_unavailable",
|
|
system_code: "ENOENT",
|
|
});
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === join("root_0:z-directory", "child"),
|
|
),
|
|
).toMatchObject({ status: "unknown" });
|
|
}));
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"propagates cancellation exactly during symlink read",
|
|
async () =>
|
|
withTemporaryRoot("rea-snapshot-cancel-race-", async (root) => {
|
|
const link = join(root, "link");
|
|
await symlink("missing-target", link);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel during symlink read");
|
|
race.mode = "cancel_readlink";
|
|
race.target = link;
|
|
race.controller = controller;
|
|
race.reason = reason;
|
|
await expectCancelledSnapshot(scenario, controller);
|
|
}),
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"prefers the exact cancellation reason over ENOENT from the pending readlink",
|
|
async () =>
|
|
withTemporaryRoot("rea-snapshot-cancel-missing-", async (root) => {
|
|
const link = join(root, "link");
|
|
await symlink("missing-target", link);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel while readlink reports ENOENT");
|
|
race.mode = "cancel_readlink_missing";
|
|
race.target = link;
|
|
race.controller = controller;
|
|
race.reason = reason;
|
|
await expectCancelledSnapshot(scenario, controller);
|
|
}),
|
|
);
|
|
|
|
it("observes cancellation after the selected directory handle closes", async () =>
|
|
withTemporaryRoot("rea-snapshot-directory-close-cancel-", async (root) => {
|
|
const directory = join(root, "selected");
|
|
await mkdir(directory);
|
|
await writeFile(join(directory, "child"), "observed child");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [directory],
|
|
});
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel after directory handle close");
|
|
race.mode = "cancel_directory_close";
|
|
race.target = directory;
|
|
race.controller = controller;
|
|
race.reason = reason;
|
|
await expectCancelledSnapshot(scenario, controller);
|
|
expect(race.closeCompleted).toBe(true);
|
|
}));
|
|
|
|
it("releases buffered names after a failed directory revalidation before visiting the next root", async () =>
|
|
withTemporaryRoot("rea-snapshot-pending-names-", async (parent) => {
|
|
const firstRoot = join(parent, "first");
|
|
const secondRoot = join(parent, "second");
|
|
await mkdir(firstRoot);
|
|
await mkdir(secondRoot);
|
|
await writeFile(join(firstRoot, "a-child"), "first");
|
|
await writeFile(join(firstRoot, "b-child"), "second");
|
|
await writeFile(join(secondRoot, "child"), "retain this root");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: parent,
|
|
filesystem_observation_paths: [firstRoot, secondRoot],
|
|
limits: { files: 4 },
|
|
});
|
|
race.mode = "directory_missing";
|
|
race.target = firstRoot;
|
|
const after = await snapshotRoots(scenario);
|
|
expect(after.files.map(({ path }) => path)).toContain("root_1:child");
|
|
expect(after.completeRoots).toEqual(["root_1"]);
|
|
expect(after.coverage.enumeration_omissions).toContainEqual({
|
|
path: "root_0:.",
|
|
reason: "entry_unavailable",
|
|
system_code: "ENOENT",
|
|
});
|
|
}));
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"propagates unexpected EIO instead of classifying it as an unavailable entry",
|
|
async () =>
|
|
withTemporaryRoot("rea-snapshot-eio-control-", async (root) => {
|
|
const link = join(root, "link");
|
|
await symlink("missing-target", link);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
race.mode = "readlink_eio";
|
|
race.target = link;
|
|
const reason = Object.assign(
|
|
new Error("injected unexpected readlink failure"),
|
|
{ code: "EIO" },
|
|
);
|
|
race.reason = reason;
|
|
const cause = await snapshotRoots(scenario).catch(
|
|
(failure: unknown) => failure,
|
|
);
|
|
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
const failure = cause as ProcessCaptureSnapshotFailure;
|
|
expect(failure.cause).toBe(reason);
|
|
expect(failure.cause).toMatchObject({ code: "EIO" });
|
|
expect(failure.userCategory).toBeUndefined();
|
|
expect(failure.snapshot).toMatchObject({
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_omissions: [] },
|
|
});
|
|
}),
|
|
);
|