diff --git a/scripts/desktop/after-pack.cjs b/scripts/desktop/after-pack.cjs index ba506293..e60bf7de 100644 --- a/scripts/desktop/after-pack.cjs +++ b/scripts/desktop/after-pack.cjs @@ -12,6 +12,51 @@ const { * @typedef {{ arch: number | string; platform: string }} PackageTarget */ +// fs.cp rewrites relative symlinks into absolute paths on the build machine +// unless verbatimSymlinks is set, and codesign rejects any link that leaves +// the bundle ("invalid destination for symbolic link in bundle"). Package +// .bin directories are CLI shims the app never runs, so they are left out. +const COPY_OPTIONS = { + /** @param {string} source */ + filter: (source) => path.basename(source) !== ".bin", + recursive: true, + verbatimSymlinks: true, +}; + +/** + * Symlinks under `rootPath` that are absolute, dangling or resolve outside it. + * @param {string} rootPath + * @returns {Promise} + */ +async function findEscapingSymlinks(rootPath) { + const root = await fs.realpath(rootPath); + /** @type {string[]} */ + const issues = []; + + /** @param {string} directory */ + async function walk(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + const linkTarget = await fs.readlink(entryPath); + const resolved = await fs.realpath(entryPath).catch(() => null); + if ( + path.isAbsolute(linkTarget) || + !resolved || + (resolved !== root && !resolved.startsWith(root + path.sep)) + ) { + issues.push(`${path.relative(root, entryPath)} -> ${linkTarget}`); + } + } else if (entry.isDirectory()) { + await walk(entryPath); + } + } + } + + await walk(root); + return issues; +} + /** * @param {string} targetPath */ @@ -130,7 +175,7 @@ async function copyPackageWithDependencies( await fs.rm(targetPackagePath, { force: true, recursive: true }); await fs.mkdir(path.dirname(targetPackagePath), { recursive: true }); - await fs.cp(sourcePackagePath, targetPackagePath, { recursive: true }); + await fs.cp(sourcePackagePath, targetPackagePath, COPY_OPTIONS); } const packageJson = JSON.parse( @@ -345,15 +390,15 @@ async function afterPack(context) { await fs.mkdir(path.dirname(targetServerNodeModulesPath), { recursive: true, }); - await fs.cp(sourceServerNodeModulesPath, targetServerNodeModulesPath, { - recursive: true, - }); + await fs.cp( + sourceServerNodeModulesPath, + targetServerNodeModulesPath, + COPY_OPTIONS, + ); await fs.rm(targetShellNodePtyPath, { force: true, recursive: true }); await fs.mkdir(targetShellNodeModulesPath, { recursive: true }); - await fs.cp(sourceShellNodePtyPath, targetShellNodePtyPath, { - recursive: true, - }); + await fs.cp(sourceShellNodePtyPath, targetShellNodePtyPath, COPY_OPTIONS); await pruneNodePtyForPlatform({ nodePtyPath: targetShellNodePtyPath, platform: context.electronPlatformName, @@ -379,9 +424,18 @@ async function afterPack(context) { nodeModulesPath: targetServerNodeModulesPath, target, }); + + // Fail here with the offending links instead of at codesign --verify. + const escapingSymlinks = await findEscapingSymlinks(resourcesPath); + if (escapingSymlinks.length > 0) { + throw new Error( + `[desktop] packaged resources contain symlinks that leave the app:\n${escapingSymlinks.join("\n")}`, + ); + } } module.exports = afterPack; module.exports.copyPackageWithDependencies = copyPackageWithDependencies; module.exports.copyPackagesWithDependencies = copyPackagesWithDependencies; +module.exports.findEscapingSymlinks = findEscapingSymlinks; module.exports.prunePackagedServerPackages = prunePackagedServerPackages; diff --git a/scripts/desktop/after-pack.test.ts b/scripts/desktop/after-pack.test.ts index e5d79980..6aa1ea3f 100644 --- a/scripts/desktop/after-pack.test.ts +++ b/scripts/desktop/after-pack.test.ts @@ -3,8 +3,10 @@ import { mkdir, mkdtemp, readFile, + readlink, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import os from "node:os"; @@ -13,20 +15,24 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); type PackageTarget = { arch: number | string; platform: string }; -const { copyPackageWithDependencies, prunePackagedServerPackages } = - require("./after-pack.cjs") as { - copyPackageWithDependencies: ( - projectDir: string, - targetNodeModulesPath: string, - packageName: string, - copiedPackages?: Set, - options?: { target?: PackageTarget }, - ) => Promise; - prunePackagedServerPackages: (options: { - nodeModulesPath: string; - target: PackageTarget; - }) => Promise; - }; +const { + copyPackageWithDependencies, + findEscapingSymlinks, + prunePackagedServerPackages, +} = require("./after-pack.cjs") as { + copyPackageWithDependencies: ( + projectDir: string, + targetNodeModulesPath: string, + packageName: string, + copiedPackages?: Set, + options?: { target?: PackageTarget }, + ) => Promise; + findEscapingSymlinks: (rootPath: string) => Promise; + prunePackagedServerPackages: (options: { + nodeModulesPath: string; + target: PackageTarget; + }) => Promise; +}; const tempRoots: string[] = []; @@ -271,6 +277,68 @@ describe("copyPackageWithDependencies", () => { }); }); +describe("symlinks in packaged dependencies", () => { + it("keeps relative links relative and leaves .bin shims out, so codesign accepts the bundle", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "after-pack-test-")); + tempRoots.push(tempRoot); + const projectDir = path.join(tempRoot, "project"); + const targetNodeModulesPath = path.join(tempRoot, "target", "node_modules"); + const updaterRoot = path.join(projectDir, "node_modules", "updater"); + + await writePackage(projectDir, "updater", { + name: "updater", + version: "1.0.0", + }); + // bun nests a dependency (and its .bin shim) next to its dependent. + const nestedSemver = path.join(updaterRoot, "node_modules", "semver"); + await mkdir(path.join(nestedSemver, "bin"), { recursive: true }); + await writeFile(path.join(nestedSemver, "bin", "semver.js"), "\n"); + await mkdir(path.join(updaterRoot, "node_modules", ".bin")); + await symlink( + "../semver/bin/semver.js", + path.join(updaterRoot, "node_modules", ".bin", "semver"), + ); + await symlink("index.js", path.join(updaterRoot, "main.js")); + + await copyPackageWithDependencies( + projectDir, + targetNodeModulesPath, + "updater", + ); + + const copied = path.join(targetNodeModulesPath, "updater"); + expect(await readlink(path.join(copied, "main.js"))).toBe("index.js"); + expect( + await stat(path.join(copied, "node_modules", ".bin")).catch(() => null), + ).toBeNull(); + expect(await findEscapingSymlinks(path.join(tempRoot, "target"))).toEqual( + [], + ); + }); + + it("reports absolute, dangling and escaping links", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "after-pack-test-")); + tempRoots.push(tempRoot); + const bundle = path.join(tempRoot, "bundle"); + await mkdir(path.join(bundle, "lib"), { recursive: true }); + await writeFile(path.join(tempRoot, "outside.js"), "\n"); + await writeFile(path.join(bundle, "lib", "inside.js"), "\n"); + await symlink( + path.join(bundle, "lib", "inside.js"), + path.join(bundle, "absolute"), + ); + await symlink("../outside.js", path.join(bundle, "escaping")); + await symlink("lib/missing.js", path.join(bundle, "dangling")); + await symlink("lib/inside.js", path.join(bundle, "fine")); + + expect((await findEscapingSymlinks(bundle)).sort()).toEqual([ + `absolute -> ${path.join(bundle, "lib", "inside.js")}`, + "dangling -> lib/missing.js", + "escaping -> ../outside.js", + ]); + }); +}); + describe("prunePackagedServerPackages", () => { it("removes other targets' Copilot runtimes and koffi, including nested copies", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "after-pack-test-"));