核对脚本能读 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
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user