diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md index b1eddde2a..98ac5add8 100644 --- a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md @@ -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 构建取证。 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md index cc76ae282..e425b36c5 100644 --- a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -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` 就是修复前会命中的形态)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e6db3d13c..bc309cd23 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a33b01a9b..dd1f5f25d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 单架构),别拿过期里程碑文字当契约。 diff --git a/scripts/check-agc-update-channel-manifests.mjs b/scripts/check-agc-update-channel-manifests.mjs index 239d03294..24f203343 100644 --- a/scripts/check-agc-update-channel-manifests.mjs +++ b/scripts/check-agc-update-channel-manifests.mjs @@ -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 }); } diff --git a/scripts/pe-version-info.mjs b/scripts/pe-version-info.mjs new file mode 100644 index 000000000..5e5405a3a --- /dev/null +++ b/scripts/pe-version-info.mjs @@ -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 }; +} diff --git a/scripts/pe-version-info.test.mjs b/scripts/pe-version-info.test.mjs new file mode 100644 index 000000000..04082141c --- /dev/null +++ b/scripts/pe-version-info.test.mjs @@ -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, + ); +});