// 游戏分发「工程源包(M2b)」上行 + 下行优先链路真实行为验收。 // // 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;本脚本**不需要浏览器**。 // // 用法: // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ // node scripts/check-game-distribution-project-bundle-e2e.mjs // E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) // // 契约来源(全部读实现确认,未按描述猜): // [1] 上行路由族(Bearer + 发布灰度):api-server/src/modules/game_distribution.rs:363-385 // [2] 阶段门(已确认 → 409 ALREADY_EXISTS;仅 awaiting_upload/upload_failed 可写 → 409 UPLOAD_NOT_ALLOWED): // api-server/...:2246-2274(ensure_project_bundle_uploadable) // [3] 整包 PUT(要求 application/octet-stream):api-server/...:2285-2390;校验失败 → 422 PROJECT_BUNDLE_VALIDATION_FAILED // (api-server/...:2277-2283 map_project_bundle_error) // [4] 分片:api-server/...:2473-2597(x-genarrative-upload-offset 头 api-server/...:96、8 MiB 上限 :88、 // 偏移/超限错误码 :2492/:2501/:2525)、upload-state :2445-2470(chunkBytes/receivedBytes) // [5] complete:api-server/...:2599-2700(未开始 → 409 UPLOAD_NOT_STARTED;校验失败删半包并 422) // [6] 校验器:module-game-distribution/src/project_bundle.rs:66-152,拒绝清单含 node_modules / .env: // :156-224(reject_forbidden_path / is_sensitive_file_name) // [7] 版本私有 payload 暴露 projectBundleBytes / projectBundleSha256 且不含对象键:api-server/...:3421-3436 // [8] 下行优先:api-server/...:3110-3160(有工程包 → source=Project,回落 package) // + /fork-source/project 无工程包时 409 FORK_SOURCE_NOT_AVAILABLE:api-server/...:3258-3270 // + downloadPath 按资产拼接:api-server/...:3174-3188 // [9] 对象键前缀 agc/project-snapshots/v1/game-distribution/(响应里绝不能出现):api-server/...:106,2757-2763 // // 复用/照抄的 helper(本脚本与其同源,注释里标了出处): // - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133(源自 ratings-e2e.mjs:15-27) // - check/brief/api/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318 // (uploadCover 又源自 owner-isolation.mjs:97-150) // - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407 // (其顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573) // - downloadBinary(按字节校验下载):lineage 脚本 :418-433 // - 对象键/私有字段泄漏判定模式:lineage 脚本 :445-452 的同类写法 // // 已知与工单描述不一致处(读实现后按实现断言,并在报告里单独标注): // - 工单说「另一个作者的 token → 403」,实现是 **404**:api-server/...:4224-4240 // (load_owner_version_or_404:非 owner 按「不存在」处理,与发行包上行族同口径)。 import { createHash, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import JSZip from 'jszip'; const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; const DEV_PASSWORD = 'GenE2e123!'; const GATE_KEY = 'game-distribution:publish'; // 工程源包对象键前缀(api-server/...:106):响应里出现它等于泄漏了对象键。 const OBJECT_KEY_PREFIX = 'agc/project-snapshots/v1/game-distribution/'; const PRIVATE_OBJECT_PATTERN = /agc\/project-snapshots|\.project\.zip|project_bundle_object_key/iu; const CHUNK_HEADER = 'x-genarrative-upload-offset'; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error( '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + 'game-distribution:publish 写入口并走完整发布链路;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' + 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', ); process.exit(2); } const devStack = JSON.parse( readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'), ); const API = ( process.env.E2E_API_BASE ?? devStack.services?.['api-server']?.url ?? '' ).replace(/\/+$/u, ''); if (!API) { console.error( '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' + '或用 E2E_API_BASE 显式指定。', ); process.exit(2); } let checks = 0; let failures = 0; const skipped = 0; function check(name, ok, detail = '') { checks += 1; if (!ok) failures += 1; console.log( `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, ); } function note(message) { console.log(`NOTE ${message}`); } const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); async function api(pathname, options = {}) { const { method = 'GET', token, body, headers = {}, binary } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; if (binary) { finalBody = binary; } else if (body !== undefined) { finalHeaders['Content-Type'] = 'application/json'; finalBody = JSON.stringify(body); } const response = await fetch(`${API}${pathname}`, { method, headers: finalHeaders, body: finalBody, signal: AbortSignal.timeout(120_000), }); const text = await response.text(); let json = null; try { json = JSON.parse(text); } catch { json = null; } return { status: response.status, text, json, data: json?.data, error: json?.error, }; } function brief(body) { const code = body?.error?.code ?? body?.json?.error?.code ?? ''; return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`; } async function register(prefix) { const phone = `${prefix}${String(Date.now()).slice(-8)}`; const response = await api('/api/auth/entry', { method: 'POST', body: { purePhoneNumber: phone, password: DEV_PASSWORD }, }); return { phone, response, token: response.data?.token }; } function gameMetadata({ title, coverAssetId }) { return { title, summary: '工程源包验收临时作品', description: '', category: '休闲', tags: ['e2e'], coverAssetId, deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } async function uploadCover(token, id) { const fileName = `project-bundle-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'project-bundle', String(id)], fileName, contentType: 'image/png', access: 'private', maxSizeBytes: COVER_PNG.length, metadata: { asset_kind: 'game_distribution_cover' }, }, }); if (ticket.status !== 200) { throw new Error( `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`, ); } const upload = ticket.data.upload; const form = new FormData(); for (const [key, value] of Object.entries(upload.formFields ?? {})) { if (value !== null && value !== undefined) form.append(key, String(value)); } form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); const put = await fetch(upload.host, { method: 'POST', body: form }); if (!put.ok) { throw new Error(`直传对象存储失败 ${put.status}`); } const confirm = await api('/api/assets/objects/confirm', { method: 'POST', token, body: { bucket: upload.bucket, objectKey: upload.objectKey, contentType: 'image/png', contentLength: COVER_PNG.length, assetKind: 'game_distribution_cover', accessPolicy: 'private', entityId: 'game-distribution-project-bundle', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } async function createGame({ token, metadata, idemKey }) { return api('/api/game-distribution/games', { method: 'POST', token, headers: { 'Idempotency-Key': idemKey }, body: metadata, }); } /// 发行包(可玩成品)ZIP:与工程源包是两份不同资产,本脚本两者都要传。 async function buildReleaseZip(marker) { const zip = new JSZip(); zip.file( 'index.html', `${marker}

${marker}

`, ); const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); return { bytes, fileCount: 1, sha256: createHash('sha256').update(bytes).digest('hex'), }; } /// 合法工程源包:包内条目覆盖 index.html / package.json / vite.config.js / src/main.js。 /// `extraFiles` 用于按用例注入禁项(如 .env);`randomBytesCount` 用于造 >8 MiB 的不可压缩负载。 async function buildProjectBundle({ marker, extraFiles = {}, randomBytesCount = 0, }) { const zip = new JSZip(); zip.file( 'index.html', `${marker}` + '
', ); zip.file( 'package.json', JSON.stringify({ name: marker, version: '0.0.0' }, null, 2), ); zip.file('vite.config.js', 'export default { build: { outDir: "dist" } };\n'); zip.file('src/main.js', `console.log(${JSON.stringify(marker)});\n`); if (randomBytesCount > 0) { // STORE:随机字节不可压缩,保证包体真的越过 8 MiB 分片边界。 zip.file('assets/blob.bin', randomBytes(randomBytesCount), { compression: 'STORE', }); } for (const [filePath, content] of Object.entries(extraFiles)) { zip.file(filePath, content); } const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); return { bytes, sha256: createHash('sha256').update(bytes).digest('hex'), entries: Object.keys(zip.files).filter((name) => !zip.files[name].dir), }; } // ---------- 对抗用例用的裸 ZIP 写手 ---------- // // JSZip 会规范化条目名、且不便于构造「符号链接条目」「伪造声明大小」这类畸形包, // 对抗用例需要一个能逐字节控制 local header / central directory 的写手。 // 只用 STORE(method=0、无 data descriptor),格式见 PKWARE APPNOTE: // local header 0x04034b50 / central 0x02014b50 / EOCD 0x06054b50。 const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[index] = value >>> 0; } return table; })(); function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } /// entries: `{ name, data, mode?, versionMadeBy?, declaredUncompressedSize? }` /// - `mode`:unix mode,写进 central directory 的高 16 位(0o120777 = 符号链接)。 /// - `declaredUncompressedSize`:故意与真实字节数不一致,用于「声明说谎」用例。 function buildRawZip(entries) { const locals = []; const centrals = []; let offset = 0; for (const entry of entries) { const nameBytes = Buffer.from(entry.name, 'utf8'); const data = Buffer.from(entry.data ?? ''); const crc = crc32(data); const declared = entry.declaredUncompressedSize ?? data.length; const versionMadeBy = entry.versionMadeBy ?? 0x031e; // 3.0 / unix const externalAttrs = ((entry.mode ?? 0o100644) & 0xffff) << 16; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); // version needed local.writeUInt16LE(0, 6); // flags local.writeUInt16LE(0, 8); // method: STORE local.writeUInt16LE(0, 10); // time local.writeUInt16LE(0, 12); // date local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); // compressed size local.writeUInt32LE(declared, 22); // uncompressed size (可被伪造) local.writeUInt16LE(nameBytes.length, 26); local.writeUInt16LE(0, 28); // extra length locals.push(local, nameBytes, data); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(versionMadeBy, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(declared, 24); central.writeUInt16LE(nameBytes.length, 28); central.writeUInt16LE(0, 30); // extra central.writeUInt16LE(0, 32); // comment central.writeUInt16LE(0, 34); // disk central.writeUInt16LE(0, 36); // internal attrs central.writeUInt32LE(externalAttrs >>> 0, 38); central.writeUInt32LE(offset, 42); centrals.push(central, nameBytes); offset += local.length + nameBytes.length + data.length; } const centralSize = centrals.reduce((sum, part) => sum + part.length, 0); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(0, 4); eocd.writeUInt16LE(0, 6); eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); eocd.writeUInt32LE(centralSize, 12); eocd.writeUInt32LE(offset, 16); eocd.writeUInt16LE(0, 20); const bytes = Buffer.concat([...locals, ...centrals, eocd]); return { bytes, sha256: createHash('sha256').update(bytes).digest('hex'), }; } /// 对抗用例的每次上传都只带一个「合法基线条目 + 一个可疑条目」, /// 这样 422 只能归因于可疑条目本身。 function adversarialZip(caseName, data = Buffer.from('x'), extra = {}) { return buildRawZip([ { name: 'package.json', data: Buffer.from('{"name":"adversarial-e2e"}') }, { name: caseName, data, ...extra }, ]); } /// 422 断言:错误码必须是 PROJECT_BUNDLE_VALIDATION_FAILED,并把 details.reason 打出来。 async function expectBundleRejected(label, versionId, bytes, expectedReasons) { // HTTP 头必须是 ByteString:把中文标签折成 ASCII 键片段。 const keyTag = label.replace(/[^A-Za-z0-9]+/gu, '-').slice(0, 48); const response = await putProjectBundle(versionId, bytes, { token: authorTokenRef.token, key: `pb-adv-${keyTag}-${stampRef.value}`, }); const reason = response.error?.details?.reason ?? ''; const codeMatches = response.status === 422 && (response.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED'; const reasonMatches = expectedReasons.includes(reason); check( `${label} → 422 PROJECT_BUNDLE_VALIDATION_FAILED(reason=${reason || '∅'})`, codeMatches && reasonMatches, `${brief(response)} expectedReason∈[${expectedReasons.join(',')}]`, ); return response; } // 供上面的 helper 使用(main 里赋值,避免把 token/stamp 一路透传)。 const authorTokenRef = { token: '' }; const stampRef = { value: 0 }; // ---------- 上行 ---------- async function putProjectBundle(versionId, bytes, { token, key }) { return api(`/api/game-distribution/versions/${versionId}/project-bundle`, { method: 'PUT', token, headers: { 'Idempotency-Key': key, 'Content-Type': 'application/octet-stream', }, binary: bytes, }); } async function putProjectBundleChunk(versionId, chunk, offset, { token, key }) { return api( `/api/game-distribution/versions/${versionId}/project-bundle/chunk`, { method: 'PUT', token, headers: { 'Idempotency-Key': key, 'Content-Type': 'application/octet-stream', [CHUNK_HEADER]: String(offset), }, binary: chunk, }, ); } async function projectBundleUploadState(versionId, token) { return api( `/api/game-distribution/versions/${versionId}/project-bundle/upload-state`, { token, }, ); } async function ownerVersion(token, versionId) { const response = await api(`/api/game-distribution/versions/${versionId}`, { token, }); return { response, version: response.data?.version ?? null }; } async function downloadBinary(pathname, token) { const headers = { ...ENVELOPE }; if (token) headers.Authorization = `Bearer ${token}`; const response = await fetch(`${API}${pathname}`, { headers, signal: AbortSignal.timeout(120_000), }); const bytes = Buffer.from(await response.arrayBuffer()); return { status: response.status, contentType: response.headers.get('content-type') ?? '', contentLength: response.headers.get('content-length') ?? '', bytes, }; } // ---------- 发布链路(照抄 lineage 脚本 :320-407 的 helper,抽成共享模块会动到其它脚本,故按工单要求照抄并注明) ---------- async function createVersion({ token, gameId, metadata, zip, stamp, tag }) { return api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token, headers: { 'Idempotency-Key': `pb-version-${tag}-${stamp}` }, body: { packageSha256: zip.sha256, packageBytes: zip.bytes.length, packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }); } async function publishToPublic({ token, admin, gameId, gameRevision, metadata, stamp, tag, label, }) { const zip = await buildReleaseZip(`release-${tag}-${stamp}`); const created = await createVersion({ token, gameId, metadata, zip, stamp, tag, }); const versionId = created.data?.versionId; check( `${label} 版本创建成功`, created.status === 200 && Boolean(versionId), `status=${created.status} ${created.text.slice(0, 160)}`, ); if (!versionId) return { versionId: null }; const upload = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token, headers: { 'Idempotency-Key': `pb-upload-${tag}-${stamp}`, 'Content-Type': 'application/zip', }, binary: zip.bytes, }, ); check( `${label} 发行包上传成功`, upload.status === 200, `status=${upload.status}`, ); const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token, headers: { 'Idempotency-Key': `pb-submit-${tag}-${stamp}` }, body: { expectedPublicationRevision: gameRevision }, }, ); check( `${label} 送审成功(202)`, submitted.status === 202, `status=${submitted.status} ${submitted.text.slice(0, 140)}`, ); const readback = await api(`/api/game-distribution/versions/${versionId}`, { token, }); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `pb-approve-${tag}-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: readback.data?.version?.publicationRevision ?? gameRevision, }, }, ); check( `${label} 管理员审核通过并公开`, approved.status === 200, `status=${approved.status} ${approved.text.slice(0, 140)}`, ); return { versionId }; } // ---------- 主流程 ---------- async function main() { console.log( `[project-bundle-e2e] api-server=${API} database=${devStack.database}`, ); const adminLogin = await api('/admin/api/login', { method: 'POST', body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, }); const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; check( '管理员登录成功', adminLogin.status === 200 && Boolean(admin), `status=${adminLogin.status}`, ); if (!admin) process.exit(1); const gate = await api('/admin/api/feature-gates', { method: 'PUT', token: admin, body: { gateKey: GATE_KEY, enabled: true, rolloutPercent: 100, allowUserIds: [], allowUserTags: [], denyUserIds: [], description: 'E2E 工程源包链路', }, }); check('发布灰度已开启', gate.status === 200, `status=${gate.status}`); const stamp = Date.now(); const suffix = String(stamp).slice(-6); stampRef.value = stamp; const author = await register('132'); const other = await register('133'); check( '作者注册拿到 token', author.response.status === 200 && Boolean(author.token), `status=${author.response.status} phone=${author.phone}`, ); check( '另一个作者注册拿到 token', other.response.status === 200 && Boolean(other.token), `status=${other.response.status} phone=${other.phone}`, ); if (!author.token || !other.token) process.exit(1); authorTokenRef.token = author.token; // ---------- fixture:作品 A ---------- const gameTitle = `工程源包 ${suffix}`; const coverAssetId = await uploadCover(author.token, stamp); const metadata = gameMetadata({ title: gameTitle, coverAssetId }); const created = await createGame({ token: author.token, metadata, idemKey: `pb-game-${stamp}`, }); const gameId = created.data?.id; const gameRevision = created.data?.publicationRevision ?? 0; check( '作品创建成功', created.status === 200 && Boolean(gameId), `status=${created.status} id=${gameId ?? ''}`, ); if (!gameId) process.exit(1); // ---------- A1:草稿版本上传合法工程源包 ---------- const validBundle = await buildProjectBundle({ marker: `proj-${suffix}` }); const v1ReleaseZip = await buildReleaseZip(`v1-${suffix}`); const v1 = await createVersion({ token: author.token, gameId, metadata, zip: v1ReleaseZip, stamp, tag: 'v1', }); const v1Id = v1.data?.versionId; check( 'v1 版本创建成功且处于可写档位(awaiting_upload)', v1.status === 200 && Boolean(v1Id) && v1.data?.status === 'awaiting_upload', `status=${v1.status} versionId=${v1Id ?? ''} versionStatus=${v1.data?.status ?? ''}`, ); if (!v1Id) process.exit(1); const put1 = await putProjectBundle(v1Id, validBundle.bytes, { token: author.token, key: `pb-put-v1-${stamp}`, }); check( 'A1 合法工程源包整包上传成功', put1.status === 200 && put1.data?.versionId === v1Id, brief(put1), ); const v1After = await ownerVersion(author.token, v1Id); check( 'A1 版本私有 payload 的 projectBundleBytes / projectBundleSha256 与上传一致', v1After.version?.projectBundleBytes === validBundle.bytes.length && v1After.version?.projectBundleSha256 === validBundle.sha256, `bytes=${v1After.version?.projectBundleBytes} 期望=${validBundle.bytes.length} ` + `sha256=${v1After.version?.projectBundleSha256 ?? ''} 期望=${validBundle.sha256}`, ); check( 'A1 版本私有 payload 不下发对象键', !PRIVATE_OBJECT_PATTERN.test(v1After.response.text) && !v1After.response.text.includes(OBJECT_KEY_PREFIX), `objectKeyPrefix=${OBJECT_KEY_PREFIX}`, ); // ---------- A3:含禁项 → 422,且不落库 ---------- const forbiddenBundle = await buildProjectBundle({ marker: `bad-${suffix}`, extraFiles: { '.env': 'SECRET=1\n' }, }); const v2 = await createVersion({ token: author.token, gameId, metadata, zip: await buildReleaseZip(`v2-${suffix}`), stamp, tag: 'v2', }); const v2Id = v2.data?.versionId; check( 'v2 版本创建成功(用于禁项用例)', v2.status === 200 && Boolean(v2Id), `status=${v2.status} versionId=${v2Id ?? ''}`, ); if (!v2Id) process.exit(1); const putBad = await putProjectBundle(v2Id, forbiddenBundle.bytes, { token: author.token, key: `pb-put-v2-${stamp}`, }); check( 'A3 含 .env 的工程源包被拒(422 PROJECT_BUNDLE_VALIDATION_FAILED)', putBad.status === 422 && (putBad.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED', brief(putBad), ); const v2After = await ownerVersion(author.token, v2Id); check( 'A3 被拒后该版本仍未落库工程源包(bytes=0 / sha256 空)', (v2After.version?.projectBundleBytes ?? -1) === 0 && !v2After.version?.projectBundleSha256, `bytes=${v2After.version?.projectBundleBytes} sha256=${JSON.stringify(v2After.version?.projectBundleSha256 ?? null)}`, ); // ---------- A7:对抗用例(校验器补强后的拒绝清单) ---------- // 预期形状读自实现:module-game-distribution/src/project_bundle.rs(目录/凭据/嵌套包 // 拒绝清单 :235-295、magic 嗅探 :326-345、凭据内容嗅探 :360-400)与共享路径规范化 // server-rs/crates/module-game-distribution/src/package.rs:158-182。 const vAdv = await createVersion({ token: author.token, gameId, metadata, zip: await buildReleaseZip(`vadv-${suffix}`), stamp, tag: 'vadv', }); const vAdvId = vAdv.data?.versionId; check( 'A7 对抗用例版本创建成功(awaiting_upload)', vAdv.status === 200 && Boolean(vAdvId), `status=${vAdv.status} versionId=${vAdvId ?? ''}`, ); if (!vAdvId) process.exit(1); // A7-1 路径穿越/畸形路径(全部 422 InvalidPath) for (const name of [ 'foo/../bar', '/etc/passwd', 'C:/evil.txt', 'a\\..\\b', './x', 'a//b', ]) { await expectBundleRejected( `A7 路径穿越 ${name}`, vAdvId, adversarialZip(name).bytes, ['InvalidPath'], ); } note( '两层防线共同覆盖这些形状:zip crate 的 enclosed_name() 只拒 NUL / 根路径 / `..` 逃逸' + '(zip-2.4.2 src/types.rs:537-555,且返回的是**未归一化**的原始条目名);' + '`a//b`、`./x`、结尾点/空格、`:`/`*`/`?`、反斜杠等由校验器自己的路径形状检查逐段拦下' + '(module-game-distribution/src/package.rs:158-182:空段 / `.` / `..` / 尾随空格或点 / 禁止字符 / 反斜杠)。' + '实测这六种形状全部 422 + reason=InvalidPath。', ); // A7-2 结尾点/空格与禁止字符 for (const name of ['a/secret.', 'a/secret ', 'src/a?.ts', 'a*b.ts']) { await expectBundleRejected( `A7 路径形状 ${name}`, vAdvId, adversarialZip(name).bytes, ['InvalidPath'], ); } // A7-3 大小写变体目录(拒绝清单按大小写折叠比对) await expectBundleRejected( 'A7 大小写变体 Node_Modules/lodash/x.js', vAdvId, adversarialZip('Node_Modules/lodash/x.js').bytes, ['DependencyDirectoryNotAllowed'], ); await expectBundleRejected( 'A7 大小写变体 .GIT/HEAD', vAdvId, adversarialZip('.GIT/HEAD').bytes, ['VersionControlDirectoryNotAllowed'], ); await expectBundleRejected( 'A7 大小写变体 .AGENT/x', vAdvId, adversarialZip('.AGENT/x').bytes, ['LocalStateDirectoryNotAllowed'], ); // A7-4 符号链接条目(central directory 高 16 位 = 0o120777) await expectBundleRejected( 'A7 符号链接条目', vAdvId, adversarialZip('link-outside', Buffer.from('/etc/passwd'), { mode: 0o120777, }).bytes, ['SymlinkNotAllowed'], ); // A7-5 嵌套包:改名成 deps.dat,只能靠 magic bytes 拦住 const innerZip = buildRawZip([ { name: 'node_modules/x/index.js', data: Buffer.from('x') }, ]); await expectBundleRejected( 'A7 嵌套 zip 改名 deps.dat', vAdvId, adversarialZip('deps.dat', innerZip.bytes).bytes, ['NestedArchiveNotAllowed'], ); // A7-6 凭据类(补强新增的目录/文件名规则) const credentialCases = [ [ '.aws/credentials', ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], ], [ '.ssh/id_ecdsa', ['CredentialDirectoryNotAllowed', 'SensitiveFileNotAllowed'], ], ['.htpasswd', ['SensitiveFileNotAllowed']], ['service-account-prod.json', ['SensitiveFileNotAllowed']], ['terraform.tfstate', ['SensitiveFileNotAllowed']], ['app.p8', ['SensitiveFileNotAllowed']], ]; for (const [name, reasons] of credentialCases) { await expectBundleRejected( `A7 凭据 ${name}`, vAdvId, adversarialZip(name).bytes, reasons, ); } // A7-7 凭据内容嗅探:无扩展名文件里放 PEM 私钥块 await expectBundleRejected( 'A7 内容嗅探(无扩展名 PEM 私钥)', vAdvId, adversarialZip( 'secrets', Buffer.from('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n'), ).bytes, ['SecretContentDetected'], ); // A7-8 声明说谎:STORE 条目把解锁声明写成 1 字节、实际 4096 字节 await expectBundleRejected( 'A7 声明说谎(声明 1 字节 / 实际 4096 字节)', vAdvId, adversarialZip('liar.bin', Buffer.alloc(4096, 0x41), { declaredUncompressedSize: 1, }).bytes, ['ReadFailed', 'InvalidArchive'], ); note( '声明说谎用例断言的是「失败关闭」而不是内存量测:服务端按声明大小 take(declared+1) 读取' + '(package.rs:115-135),多读 1 字节即判 ReadFailed,因此不会出现「声明 1 KiB、实际解压数 GiB」的放大;' + '服务端真实内存占用本脚本无法观测(未量测 RSS)。', ); // 全部拒绝用例之后:该版本仍未落库 const vAdvAfter = await ownerVersion(author.token, vAdvId); check( 'A7 全部拒绝用例后该版本仍 bytes=0 / sha256 空', (vAdvAfter.version?.projectBundleBytes ?? -1) === 0 && !vAdvAfter.version?.projectBundleSha256, `bytes=${vAdvAfter.version?.projectBundleBytes} sha256=${JSON.stringify(vAdvAfter.version?.projectBundleSha256 ?? null)}`, ); // A7-9 过度拦截对照:普通 .dat / 文本 / 无扩展名 / 二进制条目必须放行 const controlBundle = await buildProjectBundle({ marker: `adv-control-${suffix}`, extraFiles: { 'notes.dat': 'plain text payload\n', 'docs/readme.md': '# readme\n', 'notes.txt': 'notes\n', LICENSE: 'MIT\n', 'assets/logo.png': COVER_PNG, }, }); const controlPut = await putProjectBundle(vAdvId, controlBundle.bytes, { token: author.token, key: `pb-adv-control-${stamp}`, }); check( 'A7 对照:普通 .dat/文本/无扩展名/二进制条目不被过度拦截(200)', controlPut.status === 200, brief(controlPut), ); // ---------- A2:>8 MiB 不可压缩负载走两片 ---------- const bigRandomBytes = 9 * 1024 * 1024; // 9 MiB 随机字节:压缩后仍 >8 MiB const bigBundle = await buildProjectBundle({ marker: `big-${suffix}`, randomBytesCount: bigRandomBytes, }); const v3 = await createVersion({ token: author.token, gameId, metadata, zip: await buildReleaseZip(`v3-${suffix}`), stamp, tag: 'v3', }); const v3Id = v3.data?.versionId; check( 'v3 版本创建成功(用于多分片用例)', v3.status === 200 && Boolean(v3Id), `status=${v3.status} versionId=${v3Id ?? ''}`, ); if (!v3Id) process.exit(1); const state0 = await projectBundleUploadState(v3Id, author.token); const chunkBytes = state0.data?.chunkBytes ?? 0; check( 'A2 upload-state 下发权威分片大小与服务端已收字节', state0.status === 200 && chunkBytes > 0 && state0.data?.receivedBytes === 0, `chunkBytes=${chunkBytes} receivedBytes=${state0.data?.receivedBytes}`, ); check( 'A2 构造的不可压缩负载确实超过一个分片(> chunkBytes)', bigBundle.bytes.length > chunkBytes && chunkBytes > 0, `bundleBytes=${bigBundle.bytes.length} chunkBytes=${chunkBytes} entries=${bigBundle.entries.length}`, ); const firstChunk = bigBundle.bytes.subarray(0, chunkBytes); const secondChunk = bigBundle.bytes.subarray(chunkBytes); const chunk1 = await putProjectBundleChunk(v3Id, firstChunk, 0, { token: author.token, key: `pb-chunk1-${stamp}`, }); check('A2 第一片(offset=0)写入成功', chunk1.status === 200, brief(chunk1)); const state1 = await projectBundleUploadState(v3Id, author.token); check( 'A2 upload-state 权威偏移等于第一片字节数', state1.data?.receivedBytes === firstChunk.length, `receivedBytes=${state1.data?.receivedBytes} 期望=${firstChunk.length}`, ); const chunk2 = await putProjectBundleChunk( v3Id, secondChunk, firstChunk.length, { token: author.token, key: `pb-chunk2-${stamp}`, }, ); check('A2 第二片(续传偏移)写入成功', chunk2.status === 200, brief(chunk2)); const state2 = await projectBundleUploadState(v3Id, author.token); check( 'A2 upload-state 权威偏移等于整包字节数', state2.data?.receivedBytes === bigBundle.bytes.length, `receivedBytes=${state2.data?.receivedBytes} 期望=${bigBundle.bytes.length}`, ); const complete3 = await api( `/api/game-distribution/versions/${v3Id}/project-bundle/complete`, { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `pb-complete-v3-${stamp}` }, }, ); check( 'A2 分片收齐后 complete 成功', complete3.status === 200 && complete3.data?.versionId === v3Id, brief(complete3), ); const v3After = await ownerVersion(author.token, v3Id); check( 'A2 分片上传确认后的 bytes / sha256 与本地构造一致', v3After.version?.projectBundleBytes === bigBundle.bytes.length && v3After.version?.projectBundleSha256 === bigBundle.sha256, `bytes=${v3After.version?.projectBundleBytes} 期望=${bigBundle.bytes.length} ` + `sha256=${v3After.version?.projectBundleSha256 ?? ''} 期望=${bigBundle.sha256}`, ); // ---------- A4:重复确认 ---------- const putAgain = await putProjectBundle(v1Id, validBundle.bytes, { token: author.token, key: `pb-put-v1-again-${stamp}`, }); check( 'A4 同一版本二次上传被拒(409 PROJECT_BUNDLE_ALREADY_EXISTS)', putAgain.status === 409 && (putAgain.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS', brief(putAgain), ); // ---------- A6:鉴权 ---------- const anonPut = await api( `/api/game-distribution/versions/${v1Id}/project-bundle`, { method: 'PUT', headers: { 'Idempotency-Key': `pb-anon-${stamp}`, 'Content-Type': 'application/octet-stream', }, binary: validBundle.bytes, }, ); check('A6 未带 Bearer → 401', anonPut.status === 401, brief(anonPut)); const foreignPut = await putProjectBundle(v1Id, validBundle.bytes, { token: other.token, key: `pb-foreign-${stamp}`, }); check( 'A6 另一个作者的 token → 404(实现:非 owner 按不存在处理)', foreignPut.status === 404 && !String(foreignPut.text).includes(OBJECT_KEY_PREFIX), brief(foreignPut), ); note( '工单描述该用例为 403;实现是 404(api-server/...:4224-4240 load_owner_version_or_404,' + '与发行包上行族同口径:不区分「别人的版本」与「不存在」)。本脚本按实现断言 404,并在此标注差异。', ); // ---------- 发布 v1(带工程源包)→ A5 阶段门 ---------- const releaseZipV1 = v1ReleaseZip; const uploadPackageV1 = await api( `/api/game-distribution/versions/${v1Id}/package`, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `pb-release-v1-${stamp}`, 'Content-Type': 'application/zip', }, binary: releaseZipV1.bytes, }, ); check( 'v1 发行包(成品)上传成功', uploadPackageV1.status === 200, brief(uploadPackageV1), ); const submitV1 = await api(`/api/game-distribution/versions/${v1Id}/submit`, { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `pb-submit-v1-${stamp}` }, body: { expectedPublicationRevision: gameRevision }, }); check('v1 送审成功(202)', submitV1.status === 202, brief(submitV1)); const v1Readback = await ownerVersion(author.token, v1Id); const approveV1 = await api( `/admin/api/game-distribution/versions/${v1Id}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `pb-approve-v1-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: v1Readback.version?.publicationRevision ?? gameRevision, }, }, ); check('v1 管理员审核通过并公开', approveV1.status === 200, brief(approveV1)); const putAfterPublish = await putProjectBundle(v1Id, validBundle.bytes, { token: author.token, key: `pb-put-after-publish-${stamp}`, }); check( 'A5 已公开且已有工程包的版本再传 → 409 ALREADY_EXISTS(阶段门先判「已存在」)', putAfterPublish.status === 409 && (putAfterPublish.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS', brief(putAfterPublish), ); note( '实现里「已确认过工程包」先于「版本档位」判定(api-server/...:2246-2274 的注释与顺序):' + '已有工程包的已公开版本因此返回 ALREADY_EXISTS;「已公开但无工程包」的补传用例在 A5b/B9 段。', ); // 取件通道要求作品共创授权非禁止(api-server/...:3110-3145):发布后再提升授权。 const promoteFork = await api( `/api/game-distribution/games/${gameId}/fork-authorization`, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `pb-promote-${stamp}` }, body: { expectedForkAuthorization: 'forbidden', forkAuthorization: 'nonCommercial', }, }, ); check( '作品 A 共创授权提升为 nonCommercial(取件通道前提)', promoteFork.status === 200 && (promoteFork.data?.game?.forkAuthorization ?? null) === 'nonCommercial', brief(promoteFork), ); // ---------- B7:下行优先 ---------- const forkSource = await api( `/api/game-distribution/games/${gameId}/fork-source`, { token: other.token, }, ); const source = forkSource.data?.forkSource ?? null; check( 'B7 带工程包的公开作品 fork-source 200 且 source=project', forkSource.status === 200 && source?.source === 'project', brief(forkSource), ); check( 'B7 sha256 / bytes 与上传的工程源包一致', source?.sha256 === validBundle.sha256 && source?.bytes === validBundle.bytes.length, `sha256=${source?.sha256 ?? ''} 期望=${validBundle.sha256} bytes=${source?.bytes} 期望=${validBundle.bytes.length}`, ); check( 'B7 downloadPath 指向 project 资产', typeof source?.downloadPath === 'string' && source.downloadPath.endsWith('/fork-source/project') && !source.downloadPath.includes('://'), `downloadPath=${source?.downloadPath ?? ''}`, ); check( 'B7 取件元数据不含对象键', !PRIVATE_OBJECT_PATTERN.test(forkSource.text) && !forkSource.text.includes(OBJECT_KEY_PREFIX), `objectKeyPrefix=${OBJECT_KEY_PREFIX}`, ); // ---------- B8:按 downloadPath 下载并逐字节校验 ---------- const projectDownload = await downloadBinary( source?.downloadPath ?? '', other.token, ); check( 'B8 工程源包下载 200 + application/zip', projectDownload.status === 200 && projectDownload.contentType.includes('application/zip'), `status=${projectDownload.status} content-type=${projectDownload.contentType}`, ); check( 'B8 content-length 与实际字节数一致', Number(projectDownload.contentLength) === projectDownload.bytes.length, `content-length=${projectDownload.contentLength} actual=${projectDownload.bytes.length}`, ); const projectDownloadSha = createHash('sha256') .update(projectDownload.bytes) .digest('hex'); check( 'B8 下载字节 sha256 与元数据一致', projectDownloadSha === source?.sha256, `download=${projectDownloadSha} meta=${source?.sha256 ?? ''}`, ); let unzippedEntries = []; try { const archive = await JSZip.loadAsync(projectDownload.bytes); unzippedEntries = Object.keys(archive.files).filter( (name) => !archive.files[name].dir, ); } catch (error) { unzippedEntries = []; note(`B8 解压失败:${error?.message ?? error}`); } check( 'B8 下载内容可解压且包含上传的工程条目', ['index.html', 'package.json', 'vite.config.js', 'src/main.js'].every( (entry) => unzippedEntries.includes(entry), ), `entries=${unzippedEntries.join(',')}`, ); // ---------- B9:对照(无工程包的公开作品回落成品包) ---------- const controlTitle = `工程源包对照 ${suffix}`; const controlCover = await uploadCover(author.token, `${stamp}-control`); const controlMeta = gameMetadata({ title: controlTitle, coverAssetId: controlCover, }); const controlCreated = await createGame({ token: author.token, metadata: controlMeta, idemKey: `pb-control-game-${stamp}`, }); const controlGameId = controlCreated.data?.id; check( 'B9 对照作品创建成功', controlCreated.status === 200 && Boolean(controlGameId), `status=${controlCreated.status} id=${controlGameId ?? ''}`, ); if (!controlGameId) process.exit(1); const controlPublish = await publishToPublic({ token: author.token, admin, gameId: controlGameId, gameRevision: controlCreated.data?.publicationRevision ?? 0, metadata: controlMeta, stamp, tag: 'control', label: '对照', }); // A5b:已公开且**没有**工程包的版本不允许补传(此时阶段门才落到档位判定)。 const controlVersionId = controlPublish.versionId; if (controlVersionId) { const controlPut = await putProjectBundle( controlVersionId, validBundle.bytes, { token: author.token, key: `pb-control-put-${stamp}` }, ); check( 'A5b 已公开但无工程包的版本不允许补传 → 409 PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED', controlPut.status === 409 && (controlPut.error?.code ?? '') === 'PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED', brief(controlPut), ); } const controlPromote = await api( `/api/game-distribution/games/${controlGameId}/fork-authorization`, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `pb-control-promote-${stamp}` }, body: { expectedForkAuthorization: 'forbidden', forkAuthorization: 'nonCommercial', }, }, ); check( 'B9 对照作品共创授权提升为 nonCommercial', controlPromote.status === 200, brief(controlPromote), ); const controlSource = await api( `/api/game-distribution/games/${controlGameId}/fork-source`, { token: other.token }, ); check( 'B9 无工程包的作品 source 回落为 package', controlSource.status === 200 && controlSource.data?.forkSource?.source === 'package', brief(controlSource), ); const controlProjectDownload = await downloadBinary( `/api/game-distribution/games/${controlGameId}/fork-source/project`, other.token, ); check( 'B9 无工程包时 /fork-source/project → 409(不静默回落成品包)', controlProjectDownload.status === 409, `status=${controlProjectDownload.status}`, ); console.log( `[project-bundle-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); if (failures > 0) { process.exitCode = 1; } } main().catch((error) => { console.error(`[project-bundle-e2e] 未捕获异常:${error?.stack ?? error}`); process.exitCode = 1; });