import { createHash } from "node:crypto"; import fs from "node:fs/promises"; function align4(value) { return (value + 3) & ~3; } function walkFiles(node, prefix = [], output = []) { for (const [name, entry] of Object.entries(node.files || {})) { const memberPath = [...prefix, name]; if (entry.files) walkFiles(entry, memberPath, output); else output.push({ entry, memberPath: memberPath.join("/") }); } return output; } function memberIntegrity(content, blockSize = 4 * 1024 * 1024) { const blocks = []; for (let offset = 0; offset < content.length; offset += blockSize) { blocks.push(createHash("sha256").update(content.subarray(offset, offset + blockSize)).digest("hex")); } return { algorithm: "SHA256", hash: createHash("sha256").update(content).digest("hex"), blockSize, blocks, }; } export function parseAsar(archive) { if (!Buffer.isBuffer(archive) || archive.length < 16) throw new Error("invalid ASAR archive"); const jsonLength = archive.readUInt32LE(12); const headerEnd = 16 + jsonLength; if (headerEnd > archive.length) throw new Error("invalid ASAR header length"); const headerBytes = archive.subarray(16, headerEnd); const header = JSON.parse(headerBytes.toString("utf8")); const dataOffset = 8 + archive.readUInt32LE(4); return { dataOffset, header, headerBytes }; } export function readAsarMember(archive, memberPath) { const { dataOffset, header } = parseAsar(archive); const file = walkFiles(header).find((candidate) => candidate.memberPath === memberPath); if (!file || file.entry.unpacked) throw new Error(`packed ASAR member not found: ${memberPath}`); const offset = Number(file.entry.offset); return archive.subarray(dataOffset + offset, dataOffset + offset + file.entry.size); } export function replaceAsarMember(archive, memberPath, replacement) { const { dataOffset, header } = parseAsar(archive); const files = walkFiles(header); const target = files.find((candidate) => candidate.memberPath === memberPath); if (!target || target.entry.unpacked) throw new Error(`packed ASAR member not found: ${memberPath}`); const targetOffset = Number(target.entry.offset); const oldSize = target.entry.size; const delta = replacement.length - oldSize; target.entry.size = replacement.length; target.entry.integrity = memberIntegrity(replacement, target.entry.integrity?.blockSize); for (const file of files) { if (file.entry.unpacked || file === target) continue; const offset = Number(file.entry.offset); if (offset > targetOffset) file.entry.offset = String(offset + delta); } const headerBytes = Buffer.from(JSON.stringify(header)); const paddedStringLength = align4(headerBytes.length + 1); const newDataOffset = 16 + paddedStringLength; const prefix = Buffer.alloc(newDataOffset); prefix.writeUInt32LE(4, 0); prefix.writeUInt32LE(8 + paddedStringLength, 4); prefix.writeUInt32LE(4 + paddedStringLength, 8); prefix.writeUInt32LE(headerBytes.length, 12); headerBytes.copy(prefix, 16); const oldStart = dataOffset + targetOffset; const oldEnd = oldStart + oldSize; const rebuilt = Buffer.concat([prefix, archive.subarray(dataOffset, oldStart), replacement, archive.subarray(oldEnd)]); return { archive: rebuilt, headerHash: createHash("sha256").update(headerBytes).digest("hex"), }; } export async function patchAsarMember(archivePath, memberPath, transform) { const archive = await fs.readFile(archivePath); const current = readAsarMember(archive, memberPath); const replacement = Buffer.from(await transform(current.toString("utf8"))); if (replacement.equals(current)) { const { headerBytes } = parseAsar(archive); return { changed: false, headerHash: createHash("sha256").update(headerBytes).digest("hex") }; } const rebuilt = replaceAsarMember(archive, memberPath, replacement); const temporaryPath = `${archivePath}.omp-tmp-${process.pid}`; const stat = await fs.stat(archivePath); await fs.writeFile(temporaryPath, rebuilt.archive, { mode: stat.mode }); await fs.rename(temporaryPath, archivePath); return { changed: true, headerHash: rebuilt.headerHash }; }