① manifest 的 schemaVersion 补读/写失败关闭门
- 新增 validate_manifest_schema_version:只接受 GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,未知版本报「manifest schemaVersion 不受支持:{实际值}(当前支持 {当前值})」
- read_manifest 在解析后立即校验 schemaVersion,与既有的 godotProjectRoot / versions 校验同级;读到未知版本直接拒绝打开,不再被当成已知版本继续使用
- write_manifest_locked 落盘前同样校验,保证本客户端永远不会把未知 schemaVersion 写进项目(该函数是 write_manifest 与 mutate_manifest_at 共用的唯一落盘入口)
- 前向兼容取舍:刻意不做「接受未来版本 + 读时就地升级」——当前并不存在 v2 定义,凭空写一个升级只能把未知数据改写成当前版本的形状,正是本次要修掉的「静默接受」;代价是未来发 v2 时旧客户端明确报错要求升级,而不是把项目按旧结构写回
- project/manifest/import_tests.rs 补 3 条断言:当前版本必须被接受且全字段回读相等(正向断言,挡住「无条件拒绝」这种改法)、未知版本读失败且磁盘文件逐字节未变、未知版本写失败且不落盘
This commit is contained in:
@@ -1608,6 +1608,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_manifest_schema_version(&manifest.schema_version)
|
||||
.map_err(|error| format!("校验 {label} schema 版本失败:{error}"))?;
|
||||
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 {label} Godot 项目根失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
@@ -1615,6 +1617,32 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// manifest 的 `schemaVersion` 门:只接受当前唯一已知版本,未知版本一律失败关闭。
|
||||
///
|
||||
/// 为什么必须在**读侧**校验:manifest 是项目的持久化业务真相,而 [`write_manifest_locked`] 会把
|
||||
/// 整个 `GameCreationAppManifest` 重新序列化后覆盖落盘。也就是说「读一次 + 任意一次写」等价于
|
||||
/// 按**本客户端已知的结构**全量重写该文件。读到未知版本还继续,旧客户端就会把新版本文件降级写回,
|
||||
/// 静默丢掉新版本的字段与语义;这跟同批新增的资源布局 sidecar([`validate_resource_layout`]
|
||||
/// 对未知 `schemaVersion` 直接报「不支持的资源布局 schema」)是同一条失败关闭口径。
|
||||
///
|
||||
/// 前向兼容取舍:这里刻意**不做**「接受未来版本 + 读时就地升级」。就地升级需要迁移函数和迁移测试,
|
||||
/// 而当前并不存在 v2 的定义,凭空写一个「升级」只能把未知数据改写成当前版本的形状——那正是本次
|
||||
/// 要修掉的静默接受。代价是明确的:未来发 v2 时,旧客户端会报「schemaVersion 不受支持」并拒绝打开,
|
||||
/// 用户拿到的是可行动的「请升级客户端」,而不是一个已经被写坏的项目。
|
||||
///
|
||||
/// 与 [`validate_game_iteration_versions`] 等一样,读侧([`read_manifest`])和写侧
|
||||
/// ([`write_manifest_locked`])都调用本函数:读侧保证不把未知版本当成已知版本用,写侧保证本客户端
|
||||
/// 永远不会把未知版本写进项目。
|
||||
fn validate_manifest_schema_version(schema_version: &str) -> Result<(), String> {
|
||||
let supported = shared_contracts::game_creation_app::GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION;
|
||||
if schema_version != supported {
|
||||
return Err(format!(
|
||||
"manifest schemaVersion 不受支持:{schema_version}(当前支持 {supported})"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_manifest_temp_with<F>(
|
||||
path: &Path,
|
||||
temp_path: &Path,
|
||||
@@ -1721,6 +1749,8 @@ fn write_manifest_locked(
|
||||
manifest: &GameCreationAppManifest,
|
||||
allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
validate_manifest_schema_version(&manifest.schema_version)
|
||||
.map_err(|error| format!("校验 manifest schema 版本失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
|
||||
@@ -396,3 +396,77 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
|
||||
assert!(!root.join(".agent").exists());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||
.expect("create manifest parent");
|
||||
let fixture = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(payload).expect("serialize manifest fixture")
|
||||
);
|
||||
fs::write(&manifest_path, &fixture).expect("write manifest fixture");
|
||||
(manifest_path, fixture)
|
||||
}
|
||||
|
||||
/// 读侧必须明确接受当前版本:只有「拒绝未知版本」的负向断言挡不住「无条件拒绝」这种改法。
|
||||
#[test]
|
||||
fn manifest_read_accepts_the_current_schema_version() {
|
||||
let workspace = godot_import_test_path("current-manifest-schema");
|
||||
let mut manifest = new_game_creation_app_manifest("schema-project", "Schema");
|
||||
manifest.goal = Some("做一个像素动作原型".to_string());
|
||||
manifest.godot_project_root = Some("game-source".to_string());
|
||||
assert_eq!(
|
||||
manifest.schema_version,
|
||||
shared_contracts::game_creation_app::GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION
|
||||
);
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
write_manifest(&manifest_path, &manifest).expect("write current manifest");
|
||||
|
||||
let read = read_manifest(&manifest_path).expect("current schemaVersion must be accepted");
|
||||
|
||||
// 全字段回读相等,同时证明 `deny_unknown_fields` 没有把已知字段/可选字段一起拒掉。
|
||||
assert_eq!(read, manifest);
|
||||
assert_eq!(read.project_id, "schema-project");
|
||||
assert_eq!(read.godot_project_root.as_deref(), Some("game-source"));
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
/// 读到未知 `schemaVersion` 必须失败关闭,而且**只读失败**:不能在失败路径上顺手把
|
||||
/// 「新版本文件」按本客户端的结构重写一遍。
|
||||
#[test]
|
||||
fn manifest_read_rejects_an_unsupported_schema_version_without_rewriting_the_file() {
|
||||
let workspace = godot_import_test_path("unsupported-manifest-schema");
|
||||
let mut payload =
|
||||
serde_json::to_value(new_game_creation_app_manifest("schema-project", "Schema"))
|
||||
.expect("serialize manifest fixture");
|
||||
payload["schemaVersion"] = serde_json::json!("game-creation-app.manifest.v2");
|
||||
let (manifest_path, fixture) = write_raw_manifest_fixture(&workspace, &payload);
|
||||
|
||||
let error = read_manifest(&manifest_path)
|
||||
.expect_err("an unsupported manifest schemaVersion must fail closed");
|
||||
|
||||
assert!(error.contains("schemaVersion"), "unexpected error: {error}");
|
||||
assert!(error.contains("game-creation-app.manifest.v2"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&manifest_path).expect("re-read manifest fixture"),
|
||||
fixture
|
||||
);
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
/// 写侧同一口径:本客户端永远不许把未知 `schemaVersion` 写进项目,且失败发生在落盘之前。
|
||||
#[test]
|
||||
fn manifest_write_rejects_an_unsupported_schema_version_before_touching_the_file() {
|
||||
let workspace = godot_import_test_path("unsupported-manifest-write-schema");
|
||||
let manifest_path = workspace.join(".agent/manifest.json");
|
||||
let mut manifest = new_game_creation_app_manifest("schema-project", "Schema");
|
||||
manifest.schema_version = "game-creation-app.manifest.v2".to_string();
|
||||
|
||||
let error = write_manifest(&manifest_path, &manifest)
|
||||
.expect_err("writing an unsupported manifest schemaVersion must fail closed");
|
||||
|
||||
assert!(error.contains("schemaVersion"), "unexpected error: {error}");
|
||||
assert!(!manifest_path.exists());
|
||||
fs::remove_dir_all(workspace).ok();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user