From 39a7343079f08a3a4dd64333729f1f2e5f0ccedb Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:48:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B8=B8=E6=88=8F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E9=98=B6=E6=AE=B5=20A=20=E7=9A=84=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E9=A2=86=E5=9F=9F=E7=94=A8=E4=BE=8B=E5=B9=B6=E5=8B=BE=E9=80=89?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-game-distribution 新增 rejects_symlink_entries:用 ZipWriter::add_symlink 造真实 S_IFLNK 条目(unix_permissions 会掩掉类型位),去掉 is_symlink 守卫时该用例变红 - 新增 failed_validation_keeps_the_previous_publication_and_confirmed_package:第二版校验失败后已公开游戏快照与上一版已确认包身份逐字段不变,变异成顺带更新游戏记录时变红 - 新增 a_version_accepts_only_one_confirmed_package:不同摘要/字节数确认被拒为 PackageMismatch,重复确认同一份内容返回同一身份,去掉守卫时变红 - 里程碑勾选第 3 条并写明「额度超限」按包/文件/展开量/条目数/压缩比上限口径验收(该链路无按用户配额)、第 4 条只剩并发不混写待补 --- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 11 +- .../src/application.rs | 160 ++++++++++++++++++ .../module-game-distribution/src/package.rs | 26 +++ 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 8c900ec42..e3142fea0 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -50,7 +50,7 @@ - [ ] 登录用户创建服务端分配的游戏,owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销。 - [x] 服务端接收真实 ZIP 字节,重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态。 -- [ ] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。 +- [x] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。 - [ ] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。 - [ ] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。 - [ ] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。 @@ -153,11 +153,14 @@ 本轮只处理阶段 A 里纯服务端/契约可证的条目,逐条给出证据与缺口;真机、域名与 CDN 相关的条目原样保留。 - 已勾选(第 2 条:服务端接收真实 ZIP 字节、重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态) - - `cargo test -p module-game-distribution --manifest-path server-rs/Cargo.toml` → **13 passed**,其中 `accepts_root_entry_and_returns_file_manifest`(真实归档 → 展开清单)、`version_state_machine_reaches_pending_review_only_after_package_validation`(状态只在包校验通过后才前进)、`accepts_package_above_the_previous_hundred_mib_limit`、`keeps_expansion_headroom_over_package_limit`。 + - `cargo test -p module-game-distribution --manifest-path server-rs/Cargo.toml` → **16 passed**(本轮新增 3 条:符号链接拒绝、失败关闭保持原状、一份版本只接受一份已确认内容),其中 `accepts_root_entry_and_returns_file_manifest`(真实归档 → 展开清单)、`version_state_machine_reaches_pending_review_only_after_package_validation`(状态只在包校验通过后才前进)、`accepts_package_above_the_previous_hundred_mib_limit`、`keeps_expansion_headroom_over_package_limit`。 - `cargo test -p api-server --manifest-path server-rs/Cargo.toml -- package_ --test-threads=1` → **6 passed**(请求体上限对齐包上限、分片大小与 `Content-Type`、上传偏移非负、校验错误按 422 下发)。 +- 已勾选(第 3 条:缺入口、越界/重复/大小写冲突、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致)——本轮补齐 + - 校验侧:`rejects_missing_entry_sensitive_and_traversal_paths`(缺入口 / 敏感文件 / 越界路径 / 重复与大小写冲突,见 `package.rs` 的 `case_folded_paths`)、`rejects_symlink_entries`(**本轮新增**:`unix_permissions` 会把 mode 掩成 `0o777`、正常写入路径补的是 `S_IFREG`,所以只能用 `ZipWriter::add_symlink` 造真实 `S_IFLNK` 条目;变异去掉 `is_symlink` 守卫后只有该用例变红)、`keeps_expansion_headroom_over_package_limit`、`accepts_package_above_the_previous_hundred_mib_limit`、`asset_path_rejects_traversal_and_absolute_input`、`content_type_allowlist_fails_closed_for_unknown_extensions`、`extraction_only_returns_the_exact_declared_entry`。 + - 「额度超限」按现行实现口径=包 / 单文件 / 展开量 / 条目数 / 压缩比上限(`MAX_PACKAGE_BYTES` 200 MiB、`MAX_FILE_BYTES` 64 MiB、`MAX_EXPANDED_BYTES` 500 MiB、`MAX_FILE_COUNT` 10 000、压缩比 100:1),由上面两条上限用例与 api-server 的 `package_request_body_limit_covers_max_package_bytes` 覆盖;这条链路**没有按用户配额**(本机检索确认仓库里的配额实现只出现在客户端项目快照里),因此"额度超限"按上限口径验收。 + - 「失败关闭后原私有对象和公开状态保持一致」:**本轮新增** `failed_validation_keeps_the_previous_publication_and_confirmed_package`——第二版校验失败后,已公开游戏的快照与上一版已确认的包身份必须逐字段不变;变异成"每次版本状态变化都顺带碰一下游戏记录"后只有该用例变红。 - 仍未勾选(缺口写具体,避免"看起来做了") - 第 1 条(owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销):领域侧有 `owner_is_required_for_version_and_package_mutations`;接口侧代码在越权时返回 403/404,但**没有跨账号读/写/撤销的接口级用例**——缺的就是这一层。 - - 第 3 条(缺入口、越界/重复/大小写冲突、符号链接、压缩炸弹、敏感内容与额度超限均失败关闭,原私有对象与公开状态保持一致):`rejects_missing_entry_sensitive_and_traversal_paths`(含重复与大小写冲突路径,见 `package.rs` 的 `case_folded_paths`)、`keeps_expansion_headroom_over_package_limit`、`asset_path_rejects_traversal_and_absolute_input`、`content_type_allowlist_fails_closed_for_unknown_extensions`、`extraction_only_returns_the_exact_declared_entry` 覆盖了前半句的大部分;缺口两项——**额度超限**(game-distribution 链路没有配额用例,仓库里的配额用例只覆盖客户端项目快照)与**"原私有对象和公开状态保持一致"**(没有断言失败后旧私有对象/公开投影未变)。 - - 第 4 条(一份版本只接受一份已确认内容;同 key 同请求幂等、不同请求冲突;同版本并发上传不混写):`idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package` 与 api-server 的 `idempotency_key_requires_a_bounded_non_empty_header` 覆盖前两句;缺口是**并发上传不混写**没有用例。 + - 第 4 条(一份版本只接受一份已确认内容;同 key 同请求幂等、不同请求冲突;同版本并发上传不混写):前两半已覆盖——`idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、api-server 的 `idempotency_key_requires_a_bounded_non_empty_header`;**本轮新增** `a_version_accepts_only_one_confirmed_package`(不同摘要或字节数的确认被拒为 `PackageMismatch`,重复确认同一份内容返回同一包身份;变异去掉该守卫后只有它变红)。**仍缺**「同版本并发上传不混写」:串行化在 `spacetime-module` / api-server 的 CAS 那一层,领域服务本身是同步的,需要在那一层写用例。 - 第 5 条(校验可异步恢复;响应丢失、服务进程退出与客户端重试回到原版本;确定失败与未知结果可区分):只有 api-server 的 `recovery_action_covers_every_version_status` 覆盖"状态 → 恢复动作"的映射;后两句在 game-distribution 链路没有用例。 - 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过);DTO 侧有 `public_payload_exposes_cover_and_screenshot_object_keys`、`version_detail_payload_exposes_frozen_metadata_to_owner`、`package_validation_errors_are_unprocessable`;缺口是 `packages/shared/src/contracts/gameDistribution.ts` 为手写 DTO,**没有任何 Rust↔TS 一致性检查**。 diff --git a/server-rs/crates/module-game-distribution/src/application.rs b/server-rs/crates/module-game-distribution/src/application.rs index 89276f1e7..6b958a911 100644 --- a/server-rs/crates/module-game-distribution/src/application.rs +++ b/server-rs/crates/module-game-distribution/src/application.rs @@ -1110,4 +1110,164 @@ mod tests { assert_eq!(rejected.game.publication_revision, 1); assert_eq!(rejected.game.visibility, GameVisibility::Unpublished); } + /// 失败关闭不得改动上一版公开投影,也不得改动已确认的私有包身份。 + #[test] + fn failed_validation_keeps_the_previous_publication_and_confirmed_package() { + let service = service(); + create_version(&service); + // 第一版走完整链路:确认包 → 提交 → 校验通过 → 审核通过公开。 + service + .confirm_package( + "owner-1", + "version-1", + &"a".repeat(64), + 42, + idem("upload-1", "upload"), + 3, + ) + .expect("upload should succeed"); + service + .submit_version("owner-1", "version-1", idem("submit-1", "submit"), 4) + .expect("submit should start validation"); + service + .pass_validation("owner-1", "version-1", idem("pass-1", "pass"), 5) + .expect("validation should pass"); + service + .review_version( + "reviewer-1", + "version-1", + ReviewDecision::Approve { + expected_publication_revision: 0, + }, + idem("review-1", "approve"), + 6, + ) + .expect("review should publish"); + let published_game = service.get_game("game-1").expect("game should exist"); + let published_version = service + .get_version("version-1") + .expect("version should exist"); + assert_eq!(published_game.visibility, GameVisibility::Published); + assert_eq!( + published_game.active_version_id.as_deref(), + Some("version-1") + ); + + // 第二版确认另一份包后校验失败:这一步必须只影响它自己。 + service + .create_version(CreateVersionInput { + game_id: "game-1".to_string(), + owner_user_id: "owner-1".to_string(), + version_id: "version-2".to_string(), + package_sha256: "b".repeat(64), + package_bytes: 7, + idempotency: idem("create-version-2", "version-2"), + created_at_micros: 7, + }) + .expect("second version should be created"); + service + .confirm_package( + "owner-1", + "version-2", + &"b".repeat(64), + 7, + idem("upload-2", "upload"), + 8, + ) + .expect("second upload should succeed"); + service + .submit_version("owner-1", "version-2", idem("submit-2", "submit"), 9) + .expect("second submit should start validation"); + service + .fail_validation( + "owner-1", + "version-2", + "包内资源缺失".to_string(), + idem("fail-validation-2", "fail"), + 10, + ) + .expect("validation failure should be recorded"); + + // 公开投影与上一版已确认的私有包身份一字不变。 + assert_eq!( + service.get_game("game-1").expect("game should exist"), + published_game, + "校验失败不得改动已公开游戏的状态" + ); + assert_eq!( + service + .get_version("version-1") + .expect("version should exist"), + published_version, + "校验失败不得改动上一版已确认的私有包身份" + ); + assert_eq!( + service + .get_version("version-2") + .expect("version should exist") + .status, + GameVersionStatus::ValidationFailed + ); + } + + /// 一份版本只接受一份已确认内容:摘要或字节数对不上的确认被拒,重复确认同一份内容不换身份。 + #[test] + fn a_version_accepts_only_one_confirmed_package() { + let service = service(); + create_version(&service); + let confirmed = service + .confirm_package( + "owner-1", + "version-1", + &"a".repeat(64), + 42, + idem("upload-1", "upload"), + 3, + ) + .expect("first confirmation should succeed"); + assert_eq!(confirmed.snapshot.status, GameVersionStatus::Uploaded); + + let digest_mismatch = service + .confirm_package( + "owner-1", + "version-1", + &"c".repeat(64), + 42, + idem("upload-other-digest", "other"), + 4, + ) + .expect_err("another digest must not replace the confirmed package"); + assert_eq!(digest_mismatch, GameDistributionError::PackageMismatch); + let bytes_mismatch = service + .confirm_package( + "owner-1", + "version-1", + &"a".repeat(64), + 43, + idem("upload-other-bytes", "other"), + 5, + ) + .expect_err("another byte count must not replace the confirmed package"); + assert_eq!(bytes_mismatch, GameDistributionError::PackageMismatch); + + // 再次确认同一份内容:仍是同一份包身份,不会变成第二份。 + let repeated = service + .confirm_package( + "owner-1", + "version-1", + &"a".repeat(64), + 42, + idem("upload-repeat", "upload"), + 6, + ) + .expect("re-confirming the same package should be accepted"); + assert!(!repeated.replayed); + let snapshot = service + .get_version("version-1") + .expect("version should exist"); + assert_eq!(snapshot.package_sha256, "a".repeat(64)); + assert_eq!(snapshot.package_bytes, 42); + assert_eq!(snapshot.status, GameVersionStatus::Uploaded); + assert_eq!(repeated.snapshot, snapshot); + } } diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index feb85a54e..3fdf0621f 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -245,6 +245,32 @@ mod tests { ); } + /// 符号链接条目必须整体拒绝:解包器不能跟随链接把内容写到包外。 + #[test] + fn rejects_symlink_entries() { + let mut output = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut output); + writer + .start_file( + "index.html", + SimpleFileOptions::default().unix_permissions(0o644), + ) + .expect("index entry"); + writer.write_all(b"").expect("index content"); + // 只能用 `add_symlink`:`unix_permissions` 会把 mode 掩成 `0o777`, + // 正常写入路径补的是 `S_IFREG`,造不出 `S_IFLNK` 条目。 + writer + .add_symlink("escape", "/tmp", SimpleFileOptions::default()) + .expect("symlink entry"); + writer.finish().expect("finish zip"); + } + assert_eq!( + validate_release_zip(&output.into_inner()), + Err(ReleasePackageError::SymlinkNotAllowed) + ); + } + #[test] fn keeps_expansion_headroom_over_package_limit() { // 口径约束:发行包上限调整时,展开总量至少要留出两倍余量,