agent-ecosystem/scripts/electron-builder/afterPack.cjs
infiniti 9c438e7c84
fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
2026-05-16 17:34:50 +03:00

495 lines
12 KiB
JavaScript

const fs = require('node:fs');
const path = require('node:path');
const ARCH_LABELS = {
0: 'ia32',
1: 'x64',
2: 'armv7l',
3: 'arm64',
4: 'universal',
};
const TARGET_BINARY_FORMAT = {
darwin: 'mach-o',
linux: 'elf',
win32: 'pe',
};
function getArchLabel(arch) {
return ARCH_LABELS[arch] ?? String(arch);
}
async function walkFiles(rootDir) {
const files = [];
const queue = [rootDir];
while (queue.length > 0) {
const currentDir = queue.pop();
if (!currentDir) {
continue;
}
let entries;
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(absolutePath);
continue;
}
if (entry.isFile()) {
files.push(absolutePath);
}
}
}
return files;
}
async function findNodePtyRoots(appOutDir) {
const roots = [];
const queue = [appOutDir];
while (queue.length > 0) {
const currentDir = queue.pop();
if (!currentDir) {
continue;
}
const baseName = path.basename(currentDir);
if (baseName === 'node-pty') {
roots.push(currentDir);
continue;
}
let entries;
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
queue.push(path.join(currentDir, entry.name));
}
}
return roots;
}
function shouldKeepNodePtyPrebuild(entryName, platform, archLabel) {
if (!entryName.startsWith(`${platform}-`)) {
return false;
}
if (platform === 'darwin' && archLabel === 'universal') {
return (
entryName === 'darwin-universal' ||
entryName === 'darwin-arm64' ||
entryName === 'darwin-x64'
);
}
return (
entryName === `${platform}-${archLabel}` ||
(platform === 'darwin' && entryName === 'darwin-universal')
);
}
function shouldKeepNodePtyBin(entryName, platform, archLabel) {
if (!entryName.startsWith(`${platform}-`)) {
return false;
}
if (platform === 'darwin' && archLabel === 'universal') {
return (
entryName.startsWith('darwin-universal-') ||
entryName.startsWith('darwin-arm64-') ||
entryName.startsWith('darwin-x64-')
);
}
return (
entryName.startsWith(`${platform}-${archLabel}-`) ||
(platform === 'darwin' && entryName.startsWith('darwin-universal-'))
);
}
async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
const removedPaths = [];
const nodePtyRoots = await findNodePtyRoots(appOutDir);
for (const nodePtyRoot of nodePtyRoots) {
for (const [subdirName, shouldKeep] of [
['prebuilds', shouldKeepNodePtyPrebuild],
['bin', shouldKeepNodePtyBin],
]) {
const subdir = path.join(nodePtyRoot, subdirName);
let entries;
try {
entries = await fs.promises.readdir(subdir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (shouldKeep(entry.name, platform, archLabel)) {
continue;
}
const absolutePath = path.join(subdir, entry.name);
await fs.promises.rm(absolutePath, { recursive: true, force: true });
removedPaths.push(absolutePath);
}
}
}
return removedPaths;
}
function findNodeModulesSequence(segments, sequence) {
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
let matches = true;
for (let offset = 0; offset < sequence.length; offset += 1) {
if (segments[index + offset] !== sequence[offset]) {
matches = false;
break;
}
}
if (matches) {
return index;
}
}
return -1;
}
function getKnownPrunableNativeArtifactRoot(appOutDir, filePath, targetPlatform, targetArch) {
if (targetPlatform !== 'win32') {
return null;
}
const relativePath = path.relative(appOutDir, filePath);
const segments = relativePath.split(path.sep);
const conptyIndex = findNodeModulesSequence(segments, [
'node_modules',
'node-pty',
'third_party',
'conpty',
]);
const conptyArchIndex = conptyIndex + 5;
const conptyArchDir = conptyIndex >= 0 ? segments[conptyArchIndex] : null;
if (conptyArchDir?.startsWith('win10-') && conptyArchDir !== `win10-${targetArch}`) {
return path.join(appOutDir, ...segments.slice(0, conptyArchIndex + 1));
}
return null;
}
function isKnownAllowedNativeMismatch(relativePath, format, archs, targetPlatform) {
const normalizedPath = relativePath.split(path.sep).join('/');
const ssh2PageantPath = 'node_modules/ssh2/util/pagent.exe';
return (
targetPlatform === 'win32' &&
(normalizedPath === ssh2PageantPath || normalizedPath.endsWith(`/${ssh2PageantPath}`)) &&
format === 'pe' &&
archs.size === 1 &&
archs.has('ia32')
);
}
async function pruneKnownIncompatibleNativeArtifacts(appOutDir, targetPlatform, targetArch) {
const files = await walkFiles(appOutDir);
const rootsToRemove = new Set();
for (const filePath of files) {
const rootToRemove = getKnownPrunableNativeArtifactRoot(
appOutDir,
filePath,
targetPlatform,
targetArch
);
if (!rootToRemove) {
continue;
}
const metadata = await detectBinaryMetadata(filePath);
if (!metadata) {
continue;
}
if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
continue;
}
rootsToRemove.add(rootToRemove);
}
const removedPaths = [];
for (const absolutePath of rootsToRemove) {
await fs.promises.rm(absolutePath, { recursive: true, force: true });
removedPaths.push(absolutePath);
}
return removedPaths;
}
function mapMachOCpuType(cpuType) {
switch (cpuType >>> 0) {
case 0x00000007:
return 'ia32';
case 0x01000007:
return 'x64';
case 0x0000000c:
return 'armv7l';
case 0x0100000c:
return 'arm64';
default:
return null;
}
}
function parseMachO(buffer) {
if (buffer.length < 8) {
return null;
}
const magicHex = buffer.subarray(0, 4).toString('hex');
const archs = new Set();
if (magicHex === 'cafebabe' || magicHex === 'cafebabf') {
if (buffer.length < 8) {
return null;
}
const archCount = buffer.readUInt32BE(4);
const stride = magicHex === 'cafebabf' ? 32 : 20;
let offset = 8;
for (let index = 0; index < archCount; index += 1) {
if (buffer.length < offset + stride) {
break;
}
const arch = mapMachOCpuType(buffer.readUInt32BE(offset));
if (arch) {
archs.add(arch);
}
offset += stride;
}
return archs.size > 0 ? { format: 'mach-o', archs } : null;
}
if (magicHex === 'bebafeca' || magicHex === 'bfbafeca') {
if (buffer.length < 8) {
return null;
}
const archCount = buffer.readUInt32LE(4);
const stride = magicHex === 'bfbafeca' ? 32 : 20;
let offset = 8;
for (let index = 0; index < archCount; index += 1) {
if (buffer.length < offset + stride) {
break;
}
const arch = mapMachOCpuType(buffer.readUInt32LE(offset));
if (arch) {
archs.add(arch);
}
offset += stride;
}
return archs.size > 0 ? { format: 'mach-o', archs } : null;
}
if (magicHex === 'feedfacf' || magicHex === 'feedface') {
const arch = mapMachOCpuType(buffer.readUInt32BE(4));
return arch ? { format: 'mach-o', archs: new Set([arch]) } : null;
}
if (magicHex === 'cffaedfe' || magicHex === 'cefaedfe') {
const arch = mapMachOCpuType(buffer.readUInt32LE(4));
return arch ? { format: 'mach-o', archs: new Set([arch]) } : null;
}
return null;
}
function parseElf(buffer) {
if (buffer.length < 20) {
return null;
}
if (
buffer[0] !== 0x7f ||
buffer[1] !== 0x45 ||
buffer[2] !== 0x4c ||
buffer[3] !== 0x46
) {
return null;
}
const littleEndian = buffer[5] !== 2;
const machine = littleEndian ? buffer.readUInt16LE(18) : buffer.readUInt16BE(18);
const arch =
machine === 0x03
? 'ia32'
: machine === 0x3e
? 'x64'
: machine === 0x28
? 'armv7l'
: machine === 0xb7
? 'arm64'
: null;
return arch ? { format: 'elf', archs: new Set([arch]) } : null;
}
function parsePortableExecutable(buffer) {
if (buffer.length < 0x40) {
return null;
}
if (buffer[0] !== 0x4d || buffer[1] !== 0x5a) {
return null;
}
const peOffset = buffer.readUInt32LE(0x3c);
if (buffer.length < peOffset + 6) {
return null;
}
if (
buffer[peOffset] !== 0x50 ||
buffer[peOffset + 1] !== 0x45 ||
buffer[peOffset + 2] !== 0x00 ||
buffer[peOffset + 3] !== 0x00
) {
return null;
}
const machine = buffer.readUInt16LE(peOffset + 4);
const arch =
machine === 0x014c
? 'ia32'
: machine === 0x8664
? 'x64'
: machine === 0xaa64
? 'arm64'
: machine === 0x01c4
? 'armv7l'
: null;
return arch ? { format: 'pe', archs: new Set([arch]) } : null;
}
async function detectBinaryMetadata(filePath) {
const handle = await fs.promises.open(filePath, 'r');
try {
const buffer = Buffer.alloc(4096);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const slice = buffer.subarray(0, bytesRead);
return parseMachO(slice) ?? parseElf(slice) ?? parsePortableExecutable(slice);
} finally {
await handle.close();
}
}
function isBinaryCompatible(format, archs, targetPlatform, targetArch) {
if (format !== TARGET_BINARY_FORMAT[targetPlatform]) {
return false;
}
if (targetPlatform === 'darwin' && targetArch === 'universal') {
return archs.has('arm64') || archs.has('x64');
}
return archs.has(targetArch);
}
async function validateNativeBinaries(appOutDir, targetPlatform, targetArch) {
const mismatches = [];
const files = await walkFiles(appOutDir);
for (const filePath of files) {
const relativePath = path.relative(appOutDir, filePath);
const metadata = await detectBinaryMetadata(filePath);
if (!metadata) {
continue;
}
if (isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
continue;
}
if (
isKnownAllowedNativeMismatch(relativePath, metadata.format, metadata.archs, targetPlatform)
) {
continue;
}
mismatches.push({
path: relativePath,
format: metadata.format,
archs: [...metadata.archs].sort(),
});
}
return mismatches;
}
async function afterPack(context) {
const targetPlatform = context.electronPlatformName;
const targetArch = getArchLabel(context.arch);
const removedPaths = [
...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)),
...(await pruneKnownIncompatibleNativeArtifacts(
context.appOutDir,
targetPlatform,
targetArch
)),
];
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
if (mismatches.length > 0) {
const details = mismatches
.slice(0, 20)
.map((mismatch) => `- ${mismatch.path} [${mismatch.format}] -> ${mismatch.archs.join(', ')}`)
.join('\n');
throw new Error(
`Found incompatible native binaries in ${targetPlatform}-${targetArch} bundle after pruning.\n${details}`
);
}
if (removedPaths.length > 0) {
console.log(
`[afterPack] pruned ${removedPaths.length} incompatible native artifact(s) for ${targetPlatform}-${targetArch}`
);
}
}
module.exports = afterPack;
module.exports._internal = {
detectBinaryMetadata,
getArchLabel,
isBinaryCompatible,
isKnownAllowedNativeMismatch,
parseElf,
parseMachO,
parsePortableExecutable,
pruneKnownIncompatibleNativeArtifacts,
pruneNodePtyArtifacts,
validateNativeBinaries,
walkFiles,
};