核对脚本能读 Windows 安装包内部渠道身份与版本
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 侧核对口径
This commit is contained in:
kdletters
2026-09-28 22:27:18 +08:00
parent 87a26f74df
commit e055cb1d7c
7 changed files with 292 additions and 1 deletions
@@ -32,6 +32,7 @@ import {
readUpdaterPubkey,
verifyUpdaterSignature,
} from '../apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs';
import { readPortableExecutableVersionInfo } from './pe-version-info.mjs';
const OSS_BASE_URL = (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() ||
@@ -163,6 +164,39 @@ async function verifyMacosBundleIdentity(channel, key, manifest, artifactPath) {
);
}
/**
* Windows 安装包同理:PE 版本资源里的产品名与版本必须对得上清单。
* 只核对文件名等于相信文件没被换过——文件名可以改,PE 资源是产物自己写的。
*/
async function verifyWindowsBundleIdentity(
channel,
key,
manifest,
artifactPath,
) {
const channelName = channel.replace(/-win$/u, '');
const expected = resolveChannelInstallIdentity(channelName);
let info;
try {
info = readPortableExecutableVersionInfo(await readFile(artifactPath));
} catch (error) {
check(`${channel}/${key} 安装包可读出 PE 版本资源`, false, String(error));
return;
}
const manifestVersion = String(manifest.version);
check(
`${channel}/${key} 安装包版本与清单一致`,
info.fileVersion === `${manifestVersion}.0` ||
info.strings.FileVersion === manifestVersion,
`pe=${info.fileVersion}/${info.strings.FileVersion ?? ''} manifest=${manifestVersion}`,
);
check(
`${channel}/${key} 安装包身份属于本渠道`,
info.strings.ProductName === expected.productName,
`pe=${info.strings.ProductName ?? ''} expected=${expected.productName}`,
);
}
async function verifyChannel(channel, tempDir) {
console.log(`\n--- 渠道 ${channel} ---`);
const manifestUrl = `${OSS_BASE_URL}/${channel}/latest.json`;
@@ -374,6 +408,9 @@ async function verifyChannel(channel, tempDir) {
if (key.startsWith('darwin-') && artifactPath.endsWith('.app.tar.gz')) {
await verifyMacosBundleIdentity(channel, key, manifest, artifactPath);
}
if (key.startsWith('windows-') && artifactPath.endsWith('.exe')) {
await verifyWindowsBundleIdentity(channel, key, manifest, artifactPath);
}
await rm(artifactPath, { force: true });
await rm(signaturePath, { force: true });
}
+217
View File
@@ -0,0 +1,217 @@
/**
* 读取 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 };
}
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readPortableExecutableVersionInfo } from './pe-version-info.mjs';
// 真实安装包(dev-win 0.1.154 / release-win 0.1.150)已在本轮用线上对象验证过解析结果;
// 这里只守住「非 PE 输入必须失败关闭」这条,避免解析器坏掉时静默返回空身份。
test('rejects buffers that are not PE files', () => {
assert.throws(
() => readPortableExecutableVersionInfo(Buffer.alloc(128)),
/不是 PE 文件/u,
);
assert.throws(
() => readPortableExecutableVersionInfo('not a buffer'),
/需要传入 PE 文件的 Buffer/u,
);
});
test('rejects a PE without a version resource', () => {
const buffer = Buffer.alloc(512);
buffer.writeUInt16LE(0x5a4d, 0);
buffer.writeUInt32LE(0x80, 0x3c);
buffer.writeUInt32LE(0x00004550, 0x80);
buffer.writeUInt16LE(1, 0x84 + 2);
buffer.writeUInt16LE(0xe0, 0x84 + 16);
buffer.writeUInt16LE(0x10b, 0x98);
assert.throws(
() => readPortableExecutableVersionInfo(buffer),
/没有资源目录|RT_VERSION|属于任何节/u,
);
});