Files
Genarrative/scripts/pe-version-info.mjs
T
kdletters e055cb1d7c
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
核对脚本能读 Windows 安装包内部渠道身份与版本
- 新增 scripts/pe-version-info.mjs:纯 Node 解析 PE RT_VERSION(VS_FIXEDFILEINFO + StringFileInfo),带非 PE 输入失败关闭的用例
- check:agc-update-channel-manifests:下载 Windows 安装包后断言 ProductName 与 FileVersion,与 mac 侧解 Info.plist 对称;实测 dev-win 0.1.155=陶泥儿开发版/0.1.155、release-win 0.1.150=陶泥儿 Release/0.1.150
- 渠道化与渠道隔离里程碑记录:dev-win 0.1.155 于 14:26:13 发布完成(总号/清单/旧协议指针三者对齐),但该构建源码早于 assertArtifactVersionMatches,守卫仍待下次真实构建验证
- decision-log 与 pitfalls 补记 PE 侧核对口径
2026-09-28 22:27:18 +08:00

218 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 读取 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 };
}