新增版本绑定改写的窄放行通道与定向用例

- 新增第二条、也是更窄的版本放行通道 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 条继续全绿
This commit is contained in:
2026-09-11 19:42:54 +08:00
parent b1377572d6
commit 17e8e477a4
2 changed files with 453 additions and 5 deletions
@@ -1372,6 +1372,91 @@ fn no_version_removals(_manifest: &GameCreationAppManifest) -> Vec<String> {
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<T>(
root: &Path,
allowed_binding_rewrites: &dyn Fn(&GameCreationAppManifest) -> Vec<String>,
mutate: impl FnOnce(&mut GameCreationAppManifest) -> Result<T, String>,
) -> Result<T, String> {
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<String>,
) -> 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 {
@@ -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();
}