diff --git a/knip.jsonc b/knip.jsonc index 88a8d0f3310a3..eb76a7148a528 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -20,7 +20,7 @@ // Knip loads its preprocessor through a CLI option; native verification and // worktree setup (from t3.json) run directly. release-desktop.yml bundles the // Windows verify script with esbuild through a path knip does not trace, - // electron-builder loads the macOS signer by path, and mobile-e2e.ts runs + // electron-builder loads the macOS signer and the Linux afterPack hook by path, and mobile-e2e.ts runs // agent-device's CLI by path, and mobile-behavior.ts spawns its runner by path. // release-desktop.yml runs the desktop launch check through xvfb-run, which knip // does not trace. release-smoke.ts runs the nightly resolver by path. @@ -28,6 +28,7 @@ // These fixture scripts are injected into a browser page by path. "fixtures/*/*.js", "knip-schemas.ts", + "linux-after-pack.ts", "mobile-behavior/run.ts", "mobile-native-client.ts", "resolve-nightly-release.ts", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a2f949c3c1aeb..695c68a90a7f6 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -3188,6 +3188,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( buildConfig.toolsets = { appimage: "1.0.3" }; const path = yield* Path.Path; const repoRoot = yield* RepoRoot; + buildConfig.afterPack = path.join(repoRoot, "scripts/linux-after-pack.ts"); buildConfig.linux = { target: target === "AppImage" ? [target, "deb"] : [target], executableName: identity.executableName, diff --git a/scripts/linux-after-pack.test.ts b/scripts/linux-after-pack.test.ts new file mode 100644 index 0000000000000..7171ac4b1f884 --- /dev/null +++ b/scripts/linux-after-pack.test.ts @@ -0,0 +1,43 @@ +// @effect-diagnostics nodeBuiltinImport:off - Applies real file modes in a temporary app folder. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { removeSharedWriteAccess } from "./linux-after-pack.ts"; + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("removeSharedWriteAccess", () => { + it("removes group and other write access from the packed app only", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-after-pack-")); + try { + const appOutDir = NodePath.join(root, "app"); + const modes = { + "app/t3code": [0o777, 0o755], + "app/resources.pak": [0o666, 0o644], + "app/chrome-sandbox": [0o4777, 0o4755], + "app/resources/app.asar": [0o644, 0o644], + "outside/file": [0o666, 0o666], + } as const; + await NodeFSP.mkdir(NodePath.join(appOutDir, "resources"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(root, "outside")); + for (const [file, [before]] of Object.entries(modes)) { + await NodeFSP.writeFile(NodePath.join(root, file), ""); + await NodeFSP.chmod(NodePath.join(root, file), before); + } + await NodeFSP.chmod(NodePath.join(appOutDir, "resources"), 0o777); + await NodeFSP.symlink("../outside", NodePath.join(appOutDir, "linked-dir")); + + await removeSharedWriteAccess(appOutDir); + + for (const [file, [, after]] of Object.entries(modes)) { + const { mode } = await NodeFSP.stat(NodePath.join(root, file)); + expect([file, mode & 0o7777]).toEqual([file, after]); + } + const { mode: resourcesMode } = await NodeFSP.stat(NodePath.join(appOutDir, "resources")); + expect(resourcesMode & 0o7777).toBe(0o755); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/linux-after-pack.ts b/scripts/linux-after-pack.ts new file mode 100644 index 0000000000000..6401b733f6c83 --- /dev/null +++ b/scripts/linux-after-pack.ts @@ -0,0 +1,24 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +interface AfterPackContext { + readonly appOutDir: string; +} + +// Electron's Linux zips can ship world-writable files (44.10.0 has 0777 binaries), and +// electron-builder copies zip modes verbatim into the .deb and AppImage. +export default async function afterPack(context: AfterPackContext): Promise { + await removeSharedWriteAccess(context.appOutDir); +} + +// Walks by hand: recursive readdir follows directory symlinks out of the app folder. +export async function removeSharedWriteAccess(path: string): Promise { + const stats = await NodeFSP.lstat(path); + if (stats.isSymbolicLink()) return; + if (stats.mode & 0o022) await NodeFSP.chmod(path, stats.mode & 0o7755); + if (!stats.isDirectory()) return; + for (const entry of await NodeFSP.readdir(path)) { + await removeSharedWriteAccess(NodePath.join(path, entry)); + } +}