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
- 新增 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 侧核对口径
218 lines
7.4 KiB
JavaScript
218 lines
7.4 KiB
JavaScript
/**
|
||
* 读取 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 };
|
||
}
|