Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions scripts/desktop/after-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>}
*/
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
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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;
96 changes: 82 additions & 14 deletions scripts/desktop/after-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
mkdir,
mkdtemp,
readFile,
readlink,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import os from "node:os";
Expand All @@ -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<string>,
options?: { target?: PackageTarget },
) => Promise<void>;
prunePackagedServerPackages: (options: {
nodeModulesPath: string;
target: PackageTarget;
}) => Promise<void>;
};
const {
copyPackageWithDependencies,
findEscapingSymlinks,
prunePackagedServerPackages,
} = require("./after-pack.cjs") as {
copyPackageWithDependencies: (
projectDir: string,
targetNodeModulesPath: string,
packageName: string,
copiedPackages?: Set<string>,
options?: { target?: PackageTarget },
) => Promise<void>;
findEscapingSymlinks: (rootPath: string) => Promise<string[]>;
prunePackagedServerPackages: (options: {
nodeModulesPath: string;
target: PackageTarget;
}) => Promise<void>;
};

const tempRoots: string[] = [];

Expand Down Expand Up @@ -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-"));
Expand Down
Loading