fix(providers): preserve selected filesystem identities
This commit is contained in:
1 parent
9eb8ba2b29
commit
9be640a8b4
4 files changed
+106
-2
No files matched your search
@@ -210,7 +210,21 @@ const resolveInside = async (
|
||||
nodes.push({ path: absolute, metadata });
|
||||
hops += 1;
|
||||
if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" };
|
||||
const target = await readlink(absolute);
|
||||
const rawTarget = await readlink(absolute, { encoding: "buffer" });
|
||||
let target: string;
|
||||
try {
|
||||
// A replacement character can alias a different, valid sibling name.
|
||||
// Preserve a literal UTF-8 BOM too: it is part of a filesystem name.
|
||||
target = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(rawTarget);
|
||||
} catch {
|
||||
throw new ElectronFusePathFailure(
|
||||
"invalid_value",
|
||||
`Framework symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")}): ${absolute}`,
|
||||
);
|
||||
}
|
||||
// Lexical only: another alias of this root (/tmp vs /private/tmp)
|
||||
// is still an escape. Later hops stay lstat- and containment-checked.
|
||||
const absoluteTarget = isAbsolute(target);
|
||||
|
||||
@@ -41,6 +41,37 @@ const inspect = async (path: string) => {
|
||||
return result.value;
|
||||
};
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects undecodable symlink targets rather than selecting a replacement sibling",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-link-encoding-");
|
||||
const framework = join(root, "Electron Framework.framework");
|
||||
await mkdir(framework);
|
||||
await writeFile(join(framework, "\uFFFD"), thinFuseImage([0x31]).bytes);
|
||||
const alias = join(framework, "alias");
|
||||
await symlink(Buffer.from([0xff]), alias);
|
||||
await expect(stat(alias)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const result = await provider.inspect({ path: alias });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toMatchObject({
|
||||
issues: [
|
||||
{
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining("not valid UTF-8"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const validName = "\uFEFFbinary";
|
||||
await writeFile(join(framework, validName), thinFuseImage([0x31]).bytes);
|
||||
const validAlias = join(framework, "valid-alias");
|
||||
await symlink(validName, validAlias);
|
||||
expect(
|
||||
(await inspect(validAlias)).slices[0]?.sentinels[0]?.fuses[0]?.state,
|
||||
).toBe("enabled");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects framework links whose terminal separator requires a directory",
|
||||
async () => {
|
||||
|
||||
@@ -167,6 +167,13 @@ export const resolveGhidraProject = async (
|
||||
"resolve_analysis_profile",
|
||||
async () => {
|
||||
assertArtifactReadActive(signal);
|
||||
// Admit the caller's exact marker before canonicalizing its parent.
|
||||
// Darwin realpath(file/..) can succeed despite native lookup rejecting
|
||||
// that traversal; hashing the canonical sibling would select a new input.
|
||||
if (!(await lstat(selection.path)).isFile())
|
||||
throw selectionFailure(
|
||||
`Existing Ghidra project marker must be a regular file: ${selection.path}`,
|
||||
);
|
||||
const projectName = basename(selection.path, ".gpr");
|
||||
const projectPath = await realpath(dirname(selection.path));
|
||||
const program = ghidraProgramNameSchema.safeParse(
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
@@ -170,3 +178,47 @@ it.each([null, "caller stopped", { kind: "stop-project-read" }])(
|
||||
).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects a selected marker with a non-directory traversal before canonicalizing its parent",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-project-traversal-");
|
||||
await writeFile(join(root, "file"), "not a directory");
|
||||
await writeFile(join(root, "project.gpr"), "marker");
|
||||
await mkdir(join(root, "project.rep"));
|
||||
const path = `${root}/file/../project.gpr`;
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: "ENOTDIR" });
|
||||
await expect(
|
||||
resolveGhidraProject({ path }, { path: join(root, "target") }, resources),
|
||||
).rejects.toMatchObject({ diagnostics: { system_code: "ENOTDIR" } });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"follows native parent symlinks and preserves missing marker failures",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-project-selection-");
|
||||
await mkdir(join(root, "actual", "nested"), { recursive: true });
|
||||
await symlink("actual/nested", join(root, "link"));
|
||||
await writeFile(join(root, "actual", "project.gpr"), "marker");
|
||||
await mkdir(join(root, "actual", "project.rep"));
|
||||
const path = `${root}/link/../project.gpr`;
|
||||
const commitment = await resolveGhidraProject(
|
||||
{ path },
|
||||
{ path: join(root, "target") },
|
||||
resources,
|
||||
);
|
||||
expect(commitment).toMatchObject({
|
||||
configured_path: path,
|
||||
project_path: await realpath(join(root, "actual")),
|
||||
project_name: "project",
|
||||
});
|
||||
await expect(
|
||||
resolveGhidraProject(
|
||||
{ path: join(root, "missing.gpr") },
|
||||
{ path: join(root, "target") },
|
||||
resources,
|
||||
),
|
||||
).rejects.toMatchObject({ diagnostics: { system_code: "ENOENT" } });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user