fix(electron): follow absolute symlinks that stay inside the framework (#3587)

* fix(electron): follow absolute symlinks that stay inside the framework

An absolute symlink target is now passed to relativeInside(root,
target), and a null result still ends the walk as an escape. When the
target lies inside the root, resolved is cleared and the root-relative
pieces are queued on pending, which replaces the path walked so far with
that absolute target. Later hops stay under the hop limit and the
existing lstat containment checks. Provider tests now cover an absolute
Versions/Current that stays on in-framework version B and matches the
relative bundle's resolved path and SHA-256, an absolute Current outside
the framework, and an absolute in-framework link whose next hop leaves
the framework; those last two still surface an escape error.

Fixes #3380

* test(electron): cover absolute framework symlink cycles

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 07:21:37 +08:00
1 parent 2e6b38cef0
commit 0e5c511c01
2 files changed
+154 -2

No files matched your search

+11 -2
View File
@@ -211,8 +211,17 @@ const resolveInside = async (
hops += 1;
if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" };
const target = await readlink(absolute);
if (isAbsolute(target)) return { kind: "escapes" };
pending.unshift(...target.split("/").filter((part) => part !== ""));
// 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);
const relativeTarget = absoluteTarget
? relativeInside(root, target)
: target;
if (relativeTarget === null) return { kind: "escapes" };
if (absoluteTarget) resolved.length = 0;
pending.unshift(
...relativeTarget.split("/").filter((part) => part !== ""),
);
continue;
}
if (metadata.isDirectory()) {
+143
View File
@@ -507,6 +507,149 @@ describe("direct Electron Framework binary selection", () => {
});
});
describe("absolute Electron Framework symlinks", () => {
const versionedFramework = async (prefix: string) => {
const root = await createTestTempDirectory(prefix);
const app = join(root, "Fixture.app");
const framework = join(
app,
"Contents",
"Frameworks",
"Electron Framework.framework",
);
for (const [version, state] of [
["A", 0x30],
["B", 0x31],
] as const) {
await mkdir(join(framework, "Versions", version), { recursive: true });
await writeFile(
join(framework, "Versions", version, "Electron Framework"),
thinFuseImage([state]).bytes,
);
}
return { root, app, framework };
};
const expectEscapes = async (path: string) => {
const result = await provider.inspect({ path });
expect(result.ok, path).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
issues: [
expect.objectContaining({
path: ["path"],
message: expect.stringContaining("escapes"),
}),
],
});
};
const expectHopLimit = async (path: string) => {
const result = await provider.inspect({ path });
expect(result.ok, path).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
issues: [
expect.objectContaining({
path: ["path"],
message: expect.stringContaining("hop limit exceeded"),
}),
],
});
};
it("follows an absolute Versions/Current link that stays inside", async () => {
const relative = await versionedFramework(
"rea-electron-fuses-absolute-relative-",
);
const absolute = await versionedFramework(
"rea-electron-fuses-absolute-inside-",
);
await symlink("B", join(relative.framework, "Versions", "Current"));
await symlink(
join(absolute.framework, "Versions", "B"),
join(absolute.framework, "Versions", "Current"),
);
const fromRelative = await inspect(relative.app);
const fromAbsolute = await inspect(absolute.app);
const binary = join(
absolute.framework,
"Versions",
"B",
"Electron Framework",
);
const selected = join(
absolute.framework,
"Versions",
"Current",
"Electron Framework",
);
const fromSelected = await inspect(selected);
expect(fromRelative.artifact.resolved_path).toBe(
join(relative.framework, "Versions", "B", "Electron Framework"),
);
expect(fromAbsolute.artifact.resolved_path).toBe(binary);
expect(fromAbsolute.artifact.sha256).toBe(fromRelative.artifact.sha256);
expect(fromAbsolute.slices[0]?.sentinels[0]?.fuses[0]?.state).toBe(
"enabled",
);
expect(fromSelected.artifact.resolved_path).toBe(binary);
expect(fromSelected.artifact.sha256).toBe(fromRelative.artifact.sha256);
});
it("rejects an absolute Versions/Current link outside the framework", async () => {
const { root, app, framework } = await versionedFramework(
"rea-electron-fuses-absolute-outside-",
);
const outside = join(root, "outside");
await mkdir(outside);
await writeFile(
join(outside, "Electron Framework"),
thinFuseImage([0x31]).bytes,
);
await symlink(outside, join(framework, "Versions", "Current"));
await expectEscapes(app);
});
it("rejects an absolute in-framework link that then hops outside", async () => {
const { app, framework } = await versionedFramework(
"rea-electron-fuses-absolute-hop-",
);
const outside = join(app, "Contents", "Other");
await mkdir(outside);
await writeFile(
join(outside, "Electron Framework"),
thinFuseImage([0x31]).bytes,
);
await symlink("../../../Other", join(framework, "Versions", "Gate"));
await symlink(
join(framework, "Versions", "Gate"),
join(framework, "Versions", "Current"),
);
await expectEscapes(app);
await expectEscapes(
join(framework, "Versions", "Current", "Electron Framework"),
);
});
it("rejects an absolute Current cycle instead of falling back to Versions/A", async () => {
const { app, framework } = await versionedFramework(
"rea-electron-fuses-absolute-cycle-",
);
const current = join(framework, "Versions", "Current");
// A valid A binary exists, so this also proves the failed Current walk is
// not mistaken for absence and silently replaced by the fallback.
await symlink(current, current);
await expectHopLimit(app);
});
});
it.each(["Contents", "Frameworks"])(
"rejects a redirected %s for every app selection form",
async (segment) => {