核对脚本能读 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
@@ -71,5 +71,8 @@
- **`dev-mac`(v0.1.142,`pub_date=2026-09-24T11:35:31Z`,commit `c07c10c0c`)**:清单 `darwin-aarch64` 指向 `agc/dev-mac/0.1.142/陶泥儿 Release.app.tar.gz`(315,510,533 字节,HEAD 200);`.sig` 对象 420 字节且与清单签名一致;下载后字节数一致、用同一公钥验签通过。
- 结论:两个渠道的「安装包 ↔ 签名 ↔ 渠道清单」三者一致成立,条目 3 关闭。
- **统一总号(条目 1 的线上口径)**:同一脚本还会读 `agc/global-version.json`,断言「任何渠道清单版本都不高于统一总号」。本轮实测总号 `0.1.155`(`updatedAt=2026-09-28T14:11:19Z`、`channel=dev-win`、`buildId=95`),四个渠道清单都不高于它(dev-win 0.1.154 / release-win 0.1.150 / dev-mac 0.1.142 / release-mac 0.1.139);渠道低于总号是正常滞后(dev-win 那次取号后构建可能仍在跑),脚本按 INFO 提示而不是判失败,只有「渠道高于总号」才失败关闭。
- **同一轮稍后**:`dev-win` 那次构建(0.1.155,commit `3702e0f8e`)在 `14:26:13` 发布完成,`global-version.json` 与 `dev-win/latest.json` 因此都停在 `0.1.155`,旧协议指针 `agc/latest.json` 的 `sha256`/`size` 同步换到新包——「取号 → 构建 → 发布 → 指针」这条链路在一次真实 CI 运行里完整走通。
- **Windows 侧产物身份也能核对了**:本轮新增 `scripts/pe-version-info.mjs`(纯 Node 解析 PE `RT_VERSION`),核对脚本在下载 Windows 安装包后会断言 `ProductName` 与 `FileVersion`:`dev-win` 0.1.155 → `陶泥儿开发版` / `0.1.155`,`release-win` 0.1.150 → `陶泥儿 Release` / `0.1.150`,两边都 PASS。只核对文件名等于相信文件没被换过,PE 资源是产物自己写的。
- **注意(未验证项)**:`0.1.155` 这次构建的源码是 `3702e0f8e`,早于本轮给共享产物选择器加的 `assertArtifactVersionMatches()`(`c6ea0f0e3`),所以那条守卫还没有经过一次真实 Windows 构建;下一次渠道构建才能验证它在 CI 里不误伤。
- **顺带发现的真实缺陷(已在 macOS 里程碑与 decision-log 记录)**:线上 `dev-mac/0.1.142` 清单只登记 `darwin-aarch64` 是符合 2026-09-21 单架构决策的;但它指向的更新包解出来是 **0.1.139 的 release 身份包**(`world.genarrative.ai-game-creator.release` / 陶泥儿 Release)。这一条本条验收没覆盖(本条只看「地址存在 + 签名匹配」),本轮已把「包内版本与渠道身份」加进 `check:agc-update-channel-manifests`,并在 mac 构建入口补上构建期身份断言。
- 条目 5(Jenkins 归档与日志不含私钥)仍是未勾选:静态可证部分已记录在验收行上(`withCredentials` 注入 + 归档 glob 不含私钥文件),真实 Jenkins 运行日志需要一次 CI 构建取证。
@@ -54,6 +54,7 @@
- `dev-win`(0.1.154,commit `76cdb96c5`):文件名 `陶泥儿开发版_0.1.154_x64-setup.exe`、对象存在、`.sig` 与清单一致、公钥验签通过 → 默认渠道身份不变。
- `release-win`(0.1.150,commit `5c648ab9c7`):文件名 `陶泥儿 Release_0.1.150_x64-setup.exe`、签名与摘要核对通过 → 非默认渠道用独立产品名。
- **Windows 产物内部身份(本轮新增能力)**:`scripts/pe-version-info.mjs` 直接解析安装包的 PE `RT_VERSION`,核对脚本据此断言内部字段——`dev-win` 0.1.155 的 `ProductName=陶泥儿开发版`、`FileVersion=0.1.155`;`release-win` 0.1.150 的 `ProductName=陶泥儿 Release`、`FileVersion=0.1.150`。两侧都 PASS:**两个渠道的产物内部身份确实不同**,同机并存所需的「安装目录/卸载项/AppData 按身份分开」前提成立(这条以前只有文件名与源码级证据)。
- `release-mac`(0.1.139,commit `87e52860a7`):清单只登记 `darwin-aarch64`(符合 2026-09-21 决策);解出更新包内 `Info.plist` 是 `world.genarrative.ai-game-creator.release` / `陶泥儿 Release` / `0.1.139`,与清单版本、渠道身份一致。
- **`dev-mac` 有真实缺陷**:清单 `0.1.142` 指向的更新包解出来是 `world.genarrative.ai-game-creator.release` / `陶泥儿 Release` / `0.1.139`,而且与 `release-mac/0.1.139` 的产物**字节完全相同**(`sha256=1dfc9deb79f7…`)。也就是说 dev 分区里放的其实是 release 渠道那份包,渠道隔离在这一次发布上被破坏(签名字段都是真的,只有身份和版本错了)。根因是 mac 构建目录残留产物被清单扫描挑中,已在 2026-09-28 修复(构建前清空同类产物 + 构建后断言包内版本与渠道身份),需要用修好的 mac 管线重发一次 `dev-mac`。
- 新增的跨渠道断言:同一次核对里不同渠道的更新包不得字节相同(`1dfc9deb79f7: dev-mac/darwin-aarch64 = release-mac/darwin-aarch64` 就是修复前会命中的形态)。
@@ -9708,6 +9708,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(只读核对也要看包内身份):`check:agc-update-channel-manifests` 在 `AGC_UPDATE_VERIFY_DOWNLOAD=1` 时下载 mac 更新包、解出 `Info.plist` 做同样断言;同时按 2026-09-21 决策断言 mac 渠道只登记 `darwin-aarch64`(不再要求 universal 双键)。
- 决策(跨渠道产物不得同字节):同一次核对里若下载到多个渠道的更新包,脚本会断言它们的 sha256 两两不同——不同渠道的 `productName` / `identifier` 不同,产物就不可能相同。事故取证:`dev-mac/0.1.142` 的更新包与 `release-mac/0.1.139` 的更新包 sha256 都是 `1dfc9deb79f7fa5fd66d8a8a47b80e3a6ff125b8762cabfd32d3e16dec946e4e`,字节级证明 dev 分区放的是 release 渠道那次构建的产物。全量核对用 `AGC_UPDATE_CHANNELS=dev-win,dev-mac,release-win,release-mac AGC_UPDATE_VERIFY_DOWNLOAD=1`(约 950 MiB 下载)。
- 决策(统一总号也要线上核对):同一脚本读 `agc/global-version.json`,断言「任何渠道清单版本都不高于统一总号」;渠道低于总号按 INFO 提示(可能正在构建),只有渠道高于总号才失败关闭。本轮线上:总号 `0.1.155`(2026-09-28T14:11:19Z 由 dev-win 取号),四个渠道清单分别为 0.1.154 / 0.1.150 / 0.1.142 / 0.1.139,均不高于总号。
- 决策(Windows 侧也要能核产物内部身份):新增 `scripts/pe-version-info.mjs`(纯 Node 解析 PE `RT_VERSION`,无第三方依赖),核对脚本下载 Windows 安装包后断言 `ProductName` 与 `FileVersion` 等于本渠道身份与本轮版本。理由与 mac 侧一致:文件名可以改,PE 版本资源是产物自己写的;两侧对称以后「产物里的渠道身份」不再只靠源码级用例。实测 `dev-win` 0.1.155 → `陶泥儿开发版`/`0.1.155`,`release-win` 0.1.150 → `陶泥儿 Release`/`0.1.150`。
- 事实(同日稍后):`dev-win` 的 0.1.155(commit `3702e0f8e`)在 14:26:13 发布完成,总号、渠道清单与旧协议指针三者对齐;该构建的源码早于 `assertArtifactVersionMatches()`,所以那条守卫仍待下一次真实渠道构建验证。
- 决策(口径回归):macOS 现行契约是 arm64 单架构(2026-09-21 决策),里程碑里「两个 macOS 平台键指向 universal 产物」的旧文字按现行决策改写;不得据此重新切回 universal,除非按该决策给出的恢复路径补齐按架构的 Node 运行时。
- 影响范围:`apps/ai-game-creator-shell/scripts/build-macos-ci.mjs`、新增 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs` 与其 `.test.mjs`、`apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`、`scripts/check-agc-update-channel-manifests.mjs`、两份里程碑与本文件、pitfalls。
- 验证:`node --test apps/ai-game-creator-shell/scripts/*.test.mjs` → **110 passed / 0 failed**(其中定向批次 `macos-release-identity / prepare-macos-codex / verify-updater-signature / build-release / cargo-features` 60 passed;新增的 mac 身份回归用例直接喂线上那份 0.1.139 release 身份 plist,必须抛错;新增的产物版本守卫用例喂 `_0.1.153_` 残留安装包,必须抛错);`npm run check:production-ops`、`check:encoding`、`check:doc-index`、prettier、eslint、`git diff --check` 通过;只读核对对线上 `dev-win` 全 PASS(含 158 MiB 产物下载验签与旧协议 sha256 一致),对线上 `dev-mac` 精确报出上面两条 FAIL。
@@ -6103,6 +6103,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现象**:线上 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json`(`version=0.1.142`、`commit=c07c10c0c`)把更新包指向 `陶泥儿 Release.app.tar.gz`;下载解开看 `Contents/Info.plist`:`CFBundleShortVersionString=0.1.139`、`CFBundleIdentifier=world.genarrative.ai-game-creator.release`、`CFBundleName=陶泥儿 Release`。同一份清单的首装 DMG 却是 `陶泥儿开发版_0.1.142_aarch64.dmg`。也就是说签名是真的、对象也在,但**版本与渠道身份都是错的**。
- **根因**:`build-macos-ci.mjs` 以前只 `rmSync` 「本轮要写的确切文件名」,构建目录里上一轮(或其它渠道身份)留下的 `*.app.tar.gz` 不会被清;而 `generateUpdateManifest()` 是**扫描构建目录、按优先级挑产物**(同名优先级再按字典序),于是挑走了残留的 release 身份包。mac 构建机是复用 workspace 的,这类残留会长期存在。
- **判据**:只读核对要**打开产物看身份**,不能只看「地址存在 + 签名匹配」。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1`)现在会解出 mac 包的 `Info.plist`,断言「包内版本 == 清单版本」且「包内 identifier/产品名 == 本渠道身份」,并额外断言**不同渠道的更新包不得字节相同**;本轮对线上取样得到三条 FAIL,正是这个缺陷。字节级佐证:`dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 与 `release-mac/0.1.139/陶泥儿 Release.app.tar.gz` 的 sha256 完全相同(`1dfc9deb79f7…`),说明 dev 分区里放的就是 release 那一次构建的产物。
- **判据**:只读核对要**打开产物看身份**,不能只看「地址存在 + 签名匹配」。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1`)现在会解出 mac 包的 `Info.plist`、以及 Windows 安装包的 PE `RT_VERSION`(`scripts/pe-version-info.mjs`),断言「产物内版本 == 清单版本」且「产物内产品名/identifier == 本渠道身份」,并额外断言**不同渠道的更新包不得字节相同**;本轮对线上取样得到三条 FAIL,正是这个缺陷。字节级佐证:`dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 与 `release-mac/0.1.139/陶泥儿 Release.app.tar.gz` 的 sha256 完全相同(`1dfc9deb79f7…`),说明 dev 分区里放的就是 release 那一次构建的产物。
- **处理(2026-09-28 已修)**:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist` 断言版本/identifier/产品名;生成清单后再断言 `release.artifact` 就是本轮那一个。守卫在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,回归用例直接用线上那份 0.1.139 release 身份包(`node --test` 59 passed)。
- **教训**:凡是「按目录扫描挑产物」的发布步骤,都要么先清空同类产物、要么按本轮预期路径断言;只删「本轮要写的名字」等于把上一轮的坏包留在候选集里。现在共享入口 `generateUpdateManifest()` 也补了与平台无关的 `assertArtifactVersionMatches()`(文件名带 `_0.1.153_` 这类版本段时必须是本轮版本),所以即使将来某个流水线不再 `git clean -fdx`,旧安装包也会被拒绝而不是被发出去。还有一条更一般的:核对线上清单时,先看 decision-log 的现行口径(这里 macOS 已是 arm64 单架构),别拿过期里程碑文字当契约。
@@ -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,
);
});