/** * 读取 Windows PE(NSIS 安装包)里的 `VS_VERSION_INFO`。 * * 为什么需要它:AGC 的渠道身份会同时进产物文件名**和**PE 资源里的 `ProductName` / * `FileVersion`。只核对文件名等于相信「谁都没改过这个文件」;2026-09-28 的 dev-mac 事故 * 就是「清单指着一个身份/版本都不对的包」,mac 侧靠解 `Info.plist` 抓到,Windows 侧 * 需要对称的能力:确认发布出去的 `.exe` 内部确实写着本渠道产品名与本轮版本。 * * 只解析需要的部分:资源目录 → `RT_VERSION` → `VS_FIXEDFILEINFO` 与 `StringFileInfo`。 */ const SECTION_HEADER_SIZE = 40; const RESOURCE_DIRECTORY_HEADER_SIZE = 16; const RESOURCE_DIRECTORY_ENTRY_SIZE = 8; const RT_VERSION = 16; function align4(value) { return (value + 3) & ~3; } /** 读 UTF-16 字符串(含结尾 NUL),并把游标对齐到 4 字节边界。 */ function readUtf16z(buffer, offset, limit) { let end = offset; const endLimit = limit ?? buffer.length - 1; while (end + 1 < endLimit && !(buffer[end] === 0 && buffer[end + 1] === 0)) { end += 2; } return { text: buffer.toString('utf16le', offset, end), next: align4(end + 2), }; } function readOptionalHeaderLayout(buffer, peOffset) { const coffOffset = peOffset + 4; const sectionCount = buffer.readUInt16LE(coffOffset + 2); const optionalSize = buffer.readUInt16LE(coffOffset + 16); const optionalOffset = coffOffset + 20; const magic = buffer.readUInt16LE(optionalOffset); if (magic !== 0x10b && magic !== 0x20b) { throw new Error(`PE 可选头 magic 不受支持:0x${magic.toString(16)}`); } return { sectionCount, optionalOffset, sectionOffset: optionalOffset + optionalSize, dataDirectoryOffset: optionalOffset + (magic === 0x20b ? 112 : 96), }; } function readSections(buffer, layout) { const sections = []; for (let index = 0; index < layout.sectionCount; index += 1) { const base = layout.sectionOffset + index * SECTION_HEADER_SIZE; sections.push({ virtualSize: buffer.readUInt32LE(base + 8), virtualAddress: buffer.readUInt32LE(base + 12), rawSize: buffer.readUInt32LE(base + 16), rawPointer: buffer.readUInt32LE(base + 20), }); } return sections; } function rvaToOffset(sections, rva) { for (const section of sections) { const size = Math.max(section.virtualSize, section.rawSize); if (rva >= section.virtualAddress && rva < section.virtualAddress + size) { return rva - section.virtualAddress + section.rawPointer; } } throw new Error(`资源 RVA 0x${rva.toString(16)} 不属于任何节`); } function readDirectoryEntries(buffer, offset) { const named = buffer.readUInt16LE(offset + 12); const ids = buffer.readUInt16LE(offset + 14); const entries = []; for (let index = 0; index < named + ids; index += 1) { const base = offset + RESOURCE_DIRECTORY_HEADER_SIZE + index * RESOURCE_DIRECTORY_ENTRY_SIZE; entries.push({ id: buffer.readUInt32LE(base), offset: buffer.readUInt32LE(base + 4), }); } return entries; } /** 找到 `RT_VERSION` 资源并返回它在文件里的偏移与长度。 */ function findVersionResource(buffer) { if (buffer.length < 64 || buffer.readUInt16LE(0) !== 0x5a4d) { throw new Error('不是 PE 文件(缺少 MZ 头)'); } const peOffset = buffer.readUInt32LE(0x3c); if ( peOffset + 24 > buffer.length || buffer.readUInt32LE(peOffset) !== 0x00004550 ) { throw new Error('不是 PE 文件(缺少 PE 签名)'); } const layout = readOptionalHeaderLayout(buffer, peOffset); const sections = readSections(buffer, layout); const resourceRva = buffer.readUInt32LE(layout.dataDirectoryOffset + 2 * 8); if (!resourceRva) throw new Error('可执行文件没有资源目录'); const resourceOffset = rvaToOffset(sections, resourceRva); const typeEntry = readDirectoryEntries(buffer, resourceOffset).find( (entry) => entry.id === RT_VERSION, ); if (!typeEntry) throw new Error('资源目录里没有 RT_VERSION'); const nameEntry = readDirectoryEntries( buffer, resourceOffset + (typeEntry.offset & 0x7fffffff), )[0]; const languageEntry = readDirectoryEntries( buffer, resourceOffset + (nameEntry.offset & 0x7fffffff), )[0]; const dataEntryOffset = resourceOffset + languageEntry.offset; const versionRva = buffer.readUInt32LE(dataEntryOffset); const versionSize = buffer.readUInt32LE(dataEntryOffset + 4); return { offset: rvaToOffset(sections, versionRva), size: versionSize, }; } function readVersionBlock(buffer, offset, limit) { const length = buffer.readUInt16LE(offset); const valueLength = buffer.readUInt16LE(offset + 2); const key = readUtf16z(buffer, offset + 6, limit); return { offset, length, valueLength, key: key.text, valueOffset: key.next, }; } function formatFixedVersion(ms, ls) { return `${(ms >>> 16) & 0xffff}.${ms & 0xffff}.${(ls >>> 16) & 0xffff}.${ls & 0xffff}`; } function readStringFileInfo(buffer, block) { const strings = {}; let tableCursor = align4(block.valueOffset + block.valueLength); const blockEnd = block.offset + block.length; while (tableCursor + 6 <= blockEnd) { const table = readVersionBlock(buffer, tableCursor, blockEnd); if (table.length === 0) break; let entryCursor = align4(table.valueOffset + table.valueLength); const tableEnd = table.offset + table.length; while (entryCursor + 6 <= tableEnd) { const entry = readVersionBlock(buffer, entryCursor, tableEnd); if (entry.length === 0) break; strings[entry.key] = buffer .toString( 'utf16le', entry.valueOffset, entry.valueOffset + entry.valueLength * 2, ) .replace(/\0+$/u, ''); entryCursor = align4(entry.offset + entry.length); } tableCursor = align4(table.offset + table.length); } return strings; } /** * 读出 PE 版本资源里的版本号与字符串表。 * * 返回 `{ fileVersion, productVersion, strings }`;`strings` 通常含 * `ProductName` / `FileDescription` / `FileVersion` / `ProductVersion`。 */ export function readPortableExecutableVersionInfo(buffer) { if (!Buffer.isBuffer(buffer)) throw new Error('需要传入 PE 文件的 Buffer'); const resource = findVersionResource(buffer); const root = readVersionBlock( buffer, resource.offset, resource.offset + resource.size, ); if (root.key !== 'VS_VERSION_INFO') { throw new Error(`版本资源根节点异常:${root.key}`); } const fixedOffset = root.valueOffset; const signature = buffer.readUInt32LE(fixedOffset); if (signature !== 0xfeef04bd) { throw new Error(`VS_FIXEDFILEINFO 签名异常:0x${signature.toString(16)}`); } const fixed = { fileVersion: formatFixedVersion( buffer.readUInt32LE(fixedOffset + 8), buffer.readUInt32LE(fixedOffset + 12), ), productVersion: formatFixedVersion( buffer.readUInt32LE(fixedOffset + 16), buffer.readUInt32LE(fixedOffset + 20), ), }; let strings = {}; let cursor = align4(fixedOffset + root.valueLength); const rootEnd = root.offset + root.length; while (cursor + 6 <= rootEnd) { const child = readVersionBlock(buffer, cursor, rootEnd); if (child.length === 0) break; if (child.key === 'StringFileInfo') { strings = readStringFileInfo(buffer, child); } cursor = align4(child.offset + child.length); } return { ...fixed, strings }; }