From 17e8e477a414b30ccadb3e937377b24b3864373a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 19:42:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=89=88=E6=9C=AC=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E6=94=B9=E5=86=99=E7=9A=84=E7=AA=84=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E9=80=9A=E9=81=93=E4=B8=8E=E5=AE=9A=E5=90=91=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增第二条、也是更窄的版本放行通道 mutate_manifest_allowing_version_binding_rewrites:只放行"显式列出的版本改写自己的 resourceBindings",供「直接替换」写入使用 - 新增独立校验函数 validate_version_records_allow_binding_rewrites:不增不删(候选与磁盘版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有放行 ID 允许 resourceBindings 不同、其余字段(versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt)逐字段相等、未放行版本整条相等、放行集合取自写入前 manifest - 两组放行集合必须互斥:同一版本 ID 既被放行删除又被放行改写绑定 → 报「版本绑定改写放行与版本删除放行必须互斥」失败关闭 - 既有 validate_version_records_are_append_only 与既有 mutate_manifest_at_allowing_version_removals 的语义一行未改:本通道是另一条独立的窄校验路径,默认写入路径仍然只有「只追加 + 显式放行删除」 - 把写盘公共体抽成 write_manifest_locked_with_version_guard(校验器由调用方注入),write_manifest_locked 变成一行委托:装盘 / 回读 / 原子替换只有一份实现,存储行为与改造前逐行等价;唯一被参数化的就是版本校验那一步 - 新增 7 条定向用例:放行版本只改自己的绑定成功且其余版本整条相等、未放行版本改绑定被拒且字节不变、增删版本被拒、重排被拒(直接打校验器 + 真实通道两条)、放行版本改 createdAt / editPrompt / parentVersionId / versionId 四种其它字段都被拒、两组放行集合重叠被拒、默认路径与删除放行通道仍然拒绝绑定改写(证明只追加保证没被打穿) - 回归确认:既有 manifest_versions_are_append_only_at_the_storage_boundary 与 asset_delete 7 条继续全绿 --- .../src-tauri/src/project/manifest.rs | 115 +++++- .../manifest/version_binding_rewrite_tests.rs | 343 ++++++++++++++++++ 2 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 37c757da8..48e0e73ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1372,6 +1372,91 @@ fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec { Vec::new() } +/// 同 [`mutate_manifest_at`],但允许显式放行的既有版本**只改写自己的 `resourceBindings`**。 +/// +/// 这是"直接替换资源"的写入通道(PRD §3.2 的直接替换口径):把某个版本的绑定从素材 A 指向 +/// 素材 B,既不新增版本、也不删除版本。放行面被刻意压到最小: +/// +/// - **不增不删**:候选与磁盘的版本数量必须相等; +/// - **不重排**:版本 ID 序列必须逐项相同; +/// - **只有放行 ID 允许 `resourceBindings` 不同**,其余字段(`versionId` / `parentVersionId` / +/// `projectRevision` / `createdReason` / `createdAt` / `editPrompt`)逐字段相等; +/// - 未放行的版本必须整条相等; +/// - `allowed_binding_rewrites` 按**写入前的 manifest**求值(与删除放行口同口径); +/// - 与删除放行集合**互斥**:同一版本 ID 不得既被放行删除、又被放行改写绑定。 +/// +/// 既有 [`mutate_manifest_at_allowing_version_removals`] 与 +/// [`validate_version_records_are_append_only`] 的语义不受影响:本通道是**另一条**独立、更窄的 +/// 校验路径,默认路径仍然只有"只追加 + 显式放行删除"。 +pub(crate) fn mutate_manifest_allowing_version_binding_rewrites( + root: &Path, + allowed_binding_rewrites: &dyn Fn(&GameCreationAppManifest) -> Vec, + mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + let manifest_path = root.join(".agent/manifest.json"); + if let Some(parent) = manifest_path.parent() { + ensure_game_creator_private_directory_tree(parent, "manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?; + } + let _write_lock = acquire_manifest_write_lock(&manifest_path)?; + let (_, mut manifest) = read_or_create_manifest(root)?; + let allowed_binding_rewrites = allowed_binding_rewrites(&manifest); + let result = mutate(&mut manifest)?; + write_manifest_locked_with_version_guard(&manifest_path, &manifest, &|existing, candidate| { + validate_version_records_allow_binding_rewrites( + &existing.versions, + &candidate.versions, + &allowed_binding_rewrites, + &[], + ) + })?; + Ok(result) +} + +/// 项目版本数组的**第二条**放行通道:只放行"显式列出的版本改写自己的 `resourceBindings`"。 +/// +/// 与 [`validate_version_records_are_append_only`] 并列存在、互不替代:本函数不做任何"允许追加/ +/// 允许删除/允许重排"的放宽,只在一个字段上开一个显式白名单窗口。 +fn validate_version_records_allow_binding_rewrites( + existing: &[GameIterationVersion], + candidate: &[GameIterationVersion], + allowed_binding_rewrites: &[String], + allowed_version_removals: &[String], +) -> Result<(), String> { + let violation = || "项目版本记录写入后不可修改、删除或重排".to_string(); + // 两条放行通道必须互斥:既被放行删除、又被放行改写绑定,等于同时表达"这条消失"与 + // "这条保留但改绑定",语义自相矛盾,直接失败关闭。 + if allowed_binding_rewrites + .iter() + .any(|version_id| allowed_version_removals.contains(version_id)) + { + return Err("版本绑定改写放行与版本删除放行必须互斥".to_string()); + } + // 直接替换不产生新版本,也不允许任何版本消失。 + if existing.len() != candidate.len() { + return Err(violation()); + } + for (existing_version, candidate_version) in existing.iter().zip(candidate.iter()) { + if existing_version.version_id != candidate_version.version_id { + return Err(violation()); + } + let mut normalized = candidate_version.clone(); + if allowed_binding_rewrites.contains(&existing_version.version_id) { + normalized.resource_bindings = existing_version.resource_bindings.clone(); + } + if normalized != *existing_version { + return Err(violation()); + } + } + Ok(()) +} + /// 项目版本数组的写入边界:既有版本只允许按显式放行的 ID 删除,其余必须原样保留且只能追加。 fn validate_version_records_are_append_only( existing: &[GameIterationVersion], @@ -1720,6 +1805,28 @@ fn write_manifest_locked( path: &Path, manifest: &GameCreationAppManifest, allowed_version_removals: &dyn Fn(&GameCreationAppManifest) -> Vec, +) -> Result<(), String> { + write_manifest_locked_with_version_guard(path, manifest, &|existing, candidate| { + validate_version_records_are_append_only( + &existing.versions, + &candidate.versions, + &allowed_version_removals(existing), + ) + }) +} + +/// 版本数组写入的公共体:把"装盘 / 回读 / 原子替换"这套存储细节与版本校验分开。 +/// +/// 校验器由调用方注入,所以 [`write_manifest_locked`](只追加 + 显式放行删除)与 +/// [`mutate_manifest_allowing_version_binding_rewrites`](只放行显式版本的绑定改写)共用同一份 +/// 装盘实现,不会出现两份略有差异的原子替换路径。存储行为与改造前逐行等价。 +fn write_manifest_locked_with_version_guard( + path: &Path, + manifest: &GameCreationAppManifest, + version_guard: &dyn Fn( + &GameCreationAppManifest, + &GameCreationAppManifest, + ) -> Result<(), String>, ) -> Result<(), String> { validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; @@ -1727,11 +1834,7 @@ fn write_manifest_locked( .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if manifest_storage_exists(path)? { let existing = read_manifest(path)?; - validate_version_records_are_append_only( - &existing.versions, - &manifest.versions, - &allowed_version_removals(&existing), - )?; + version_guard(&existing, manifest)?; } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { @@ -1814,6 +1917,8 @@ mod classification_tests; mod import_tests; #[cfg(test)] mod recovery_tests; +#[cfg(test)] +mod version_binding_rewrite_tests; #[cfg(test)] mod npm_scaffold_tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs new file mode 100644 index 000000000..3ebde4c55 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs @@ -0,0 +1,343 @@ +use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; + +/// 「直接替换」的写入通道用例:只放行显式版本的 `resourceBindings` 改写,其余一切照旧失败关闭。 +/// +/// 这些用例同时是"只追加保证没有被这条新通道打穿"的证据:不增、不删、不重排、不改别的字段。 + +fn unique_binding_rewrite_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-binding-rewrite-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn binding(resource_id: &str) -> GameIterationVersionResourceBinding { + GameIterationVersionResourceBinding { + slot_id: format!("asset:{resource_id}"), + resource_id: resource_id.to_string(), + } +} + +fn binding_rewrite_version( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, + resource_ids: &[&str], +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: resource_ids.iter().copied().map(binding).collect(), + created_reason, + created_at: project_revision, + edit_prompt: None, + } +} + +/// 三版本项目:root → child → grandchild,绑定集合各不同,供"只放行一条"的对照使用。 +fn binding_rewrite_fixture(test_name: &str) -> (PathBuf, PathBuf, GameCreationAppManifest) { + let root = unique_binding_rewrite_root(test_name); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-binding-rewrite", "绑定改写项目"); + manifest.versions = vec![ + binding_rewrite_version( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + &["asset-a"], + ), + binding_rewrite_version( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-a", "asset-b"], + ), + binding_rewrite_version( + "version-grandchild", + Some("version-child"), + 3, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-a", "asset-b", "asset-c"], + ), + ]; + write_manifest(&manifest_path, &manifest).expect("write versioned manifest fixture"); + (root, manifest_path, manifest) +} + +/// 放行的版本:只改自己的 `resourceBindings` 成功,其余版本与其余字段逐字不动。 +#[test] +fn binding_rewrite_allows_only_the_whitelisted_version_bindings() { + let (root, manifest_path, installed) = binding_rewrite_fixture("allowed"); + let version_id = "version-child".to_string(); + + mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + let version = manifest + .versions + .iter_mut() + .find(|version| version.version_id == version_id) + .expect("child version exists"); + version.resource_bindings = vec![binding("asset-a"), binding("asset-b2")]; + Ok(()) + }, + ) + .expect("放行版本改写自己的绑定必须成功"); + + let written = read_manifest(&manifest_path).expect("read rewritten manifest"); + assert_eq!(written.versions.len(), installed.versions.len(), "不得增删版本"); + assert_eq!( + written.versions[0], installed.versions[0], + "未放行版本必须整条相等" + ); + assert_eq!( + written.versions[2], installed.versions[2], + "未放行版本必须整条相等" + ); + // 放行版本:除绑定外逐字段相等。 + let rewritten = &written.versions[1]; + let original = &installed.versions[1]; + assert_eq!(rewritten.version_id, original.version_id); + assert_eq!(rewritten.parent_version_id, original.parent_version_id); + assert_eq!(rewritten.project_revision, original.project_revision); + assert_eq!(rewritten.created_reason, original.created_reason); + assert_eq!(rewritten.created_at, original.created_at); + assert_eq!(rewritten.edit_prompt, original.edit_prompt); + assert_eq!( + rewritten.resource_bindings, + vec![binding("asset-a"), binding("asset-b2")] + ); + + fs::remove_dir_all(root).ok(); +} + +/// 未放行的版本改绑定 → 拒绝,manifest 字节不变。 +#[test] +fn binding_rewrite_rejects_versions_outside_the_whitelist() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("outside-whitelist"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + // 放行的是 child,改的却是 root。 + manifest.versions[0].resource_bindings = vec![binding("asset-z")]; + Ok(()) + }, + ) + .expect_err("未放行版本改绑定必须被拒绝"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before, + "被拒绝的写入不得改动 manifest" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 不增不删:追加新版本、删除版本都被这条通道拒绝(它不放行任何增删)。 +#[test] +fn binding_rewrite_rejects_version_additions_and_removals() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("no-add-no-remove"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + let version_id = "version-child".to_string(); + + let append_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + manifest.versions.push(binding_rewrite_version( + "version-appended", + Some("version-grandchild"), + 4, + GameIterationVersionCreatedReason::AgentRevision, + &["asset-d"], + )); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得追加版本"); + assert!(append_error.contains("不可修改、删除或重排"), "{append_error}"); + + let remove_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec![version_id.clone()], + |manifest| { + manifest.versions.remove(2); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得删除版本"); + assert!(remove_error.contains("不可修改、删除或重排"), "{remove_error}"); + + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejections"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 不重排:版本 ID 序列一变就必须拒绝。 +/// +/// 集成路径上"换序"会先被版本图校验拦下(父版本必须先于子版本存在),所以这里两条都测: +/// ① 直接打校验器,精确命中"不重排"分支;② 走真实通道,确认任何换序都失败关闭且字节不变。 +#[test] +fn binding_rewrite_rejects_version_reordering() { + let (root, manifest_path, installed) = binding_rewrite_fixture("no-reorder"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let mut reordered = installed.versions.clone(); + reordered.swap(1, 2); + let direct_error = validate_version_records_allow_binding_rewrites( + &installed.versions, + &reordered, + &[], + &[], + ) + .expect_err("版本 ID 序列变化必须被拒绝"); + assert!(direct_error.contains("不可修改、删除或重排"), "{direct_error}"); + + let channel_error = mutate_manifest_allowing_version_binding_rewrites( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + manifest.versions.swap(1, 2); + Ok(()) + }, + ) + .expect_err("绑定改写通道不得重排版本"); + assert!( + channel_error.contains("项目版本"), + "拒绝原因必须仍然来自版本校验:{channel_error}" + ); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 放行版本改**其它字段**同样拒绝(每一条都先通过版本图校验,才能精确命中改写守卫)。 +#[test] +fn binding_rewrite_rejects_other_field_changes_on_the_whitelisted_version() { + let (root, manifest_path, _installed) = binding_rewrite_fixture("other-fields"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + let version_id = "version-child".to_string(); + + let allowed = |_: &GameCreationAppManifest| vec![version_id.clone()]; + let cases: [(&str, fn(&mut GameCreationAppManifest)); 4] = [ + ("createdAt", |manifest| { + manifest.versions[1].created_at = 1; + }), + ("editPrompt", |manifest| { + manifest.versions[1].edit_prompt = Some("重新出图".to_string()); + }), + ("parentVersionId", |manifest| { + manifest.versions[2].parent_version_id = Some("version-root".to_string()); + }), + ("versionId", |manifest| { + manifest.versions[2].version_id = "version-renamed".to_string(); + }), + ]; + + for (label, mutate) in cases { + let error = mutate_manifest_allowing_version_binding_rewrites(&root, &allowed, |manifest| { + mutate(manifest); + Ok(()) + }) + .err() + .unwrap_or_else(|| panic!("{label} 必须被拒绝")); + assert!( + error.contains("不可修改、删除或重排"), + "{label} 的拒绝原因必须来自版本改写守卫:{error}" + ); + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before, + "{label} 被拒绝后字节不得变化" + ); + } + + fs::remove_dir_all(root).ok(); +} + +/// 两组放行集合必须互斥:同一版本 ID 既被放行删除、又被放行改写绑定 → 失败关闭。 +#[test] +fn binding_rewrite_whitelist_must_be_disjoint_from_version_removals() { + let (root, manifest_path, installed) = binding_rewrite_fixture("disjoint-whitelists"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let error = validate_version_records_allow_binding_rewrites( + &installed.versions, + &installed.versions, + &["version-child".to_string()], + &["version-child".to_string()], + ) + .expect_err("两组放行集合重叠必须被拒绝"); + assert!(error.contains("必须互斥"), "{error}"); + + // 真实写入路径也拿不到重叠放行:该通道固定传空删除集合。 + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejection"), + before + ); + + fs::remove_dir_all(root).ok(); +} + +/// 只追加保证没有被打穿:默认路径与删除放行通道仍然拒绝绑定改写。 +#[test] +fn binding_rewrite_channel_does_not_loosen_the_default_or_removal_paths() { + let (root, manifest_path, installed) = binding_rewrite_fixture("default-path-unchanged"); + let before = fs::read(&manifest_path).expect("read manifest bytes before rejection"); + + let default_error = mutate_manifest_at(&root, |manifest| { + manifest.versions[1].resource_bindings = vec![binding("asset-b2")]; + Ok(()) + }) + .expect_err("默认写入路径必须继续拒绝绑定改写"); + assert!( + default_error.contains("不可修改、删除或重排"), + "{default_error}" + ); + + let removal_channel_error = mutate_manifest_at_allowing_version_removals( + &root, + &|_manifest| vec!["version-child".to_string()], + |manifest| { + // 即使用删除放行口把这条列进去,它也不能"保留下来顺便改绑定"。 + manifest.versions[1].resource_bindings = vec![binding("asset-b2")]; + Ok(()) + }, + ) + .expect_err("删除放行通道不得用于绑定改写"); + assert!( + removal_channel_error.contains("不可修改、删除或重排"), + "{removal_channel_error}" + ); + + assert_eq!( + fs::read(&manifest_path).expect("read manifest bytes after rejections"), + before + ); + let unchanged = read_manifest(&manifest_path).expect("read unchanged manifest"); + assert_eq!(unchanged.versions.len(), installed.versions.len()); + + fs::remove_dir_all(root).ok(); +}