修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性
修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性(PR #316 review) - assets.rs:2048-2049 [bug · medium] 已被修复:删除登记在「身份 + revision 两项 CAS 都已通过」后遇到素材已不在 manifest 时按 no-op 成功收敛并照常推进 revision,不再报「项目资源不存在」;新增 `asset_delete_retry_converges_when_only_the_revision_advance_failed` 复现「manifest 已提交、revision 未推进」并验证重试收敛(原「不存在的素材必须报错」断言相应改为幂等语义,前端只消费 assetId/committedProjectRevision)。 - assets.rs:2035-2042 [performance · low] 已被修复:引用集合只算一次并用 `HashSet<String>` 做成员判定,`retain` 不再是 O(V²);版本守卫闭包按约定在写入前另行求值、拿不到该可变借用,故两次扫描保留并已在注释里说明。 - commands.rs:1032 [maintainability · medium] 已被修复:`RenameLocalProjectAssetInput` 补 `expectedProjectId` / `expectedProjectRevision`,`rename_local_project_asset_at` 与删除/分类更新同口径做两段身份复核 + revision CAS,失败时磁盘、manifest、revision 全不动;新增 CAS 用例(陈旧 revision / 跨项目身份 / 空身份)。注意:前端 `confirmResourceRename` 必须同时补传这两个字段(TS 侧由前端 owner 落地)。 - asset_rename.rs:84 [bug · low] 已被修复:新增 `asset_frame_directory_matches`,目录段在 Windows/macOS 按大小写不敏感比较(Linux 保持敏感),与文件名比较同口径;新增 `Assets/hero.png` 帧对齐用例。 - asset_rename.rs:249-250 [bug · medium] 已被修复:revision 推进失败改为报 `reconciliation-required: 素材已改名…且 manifest 已落盘,但项目 revision 未能推进`,明确这是「已提交但对账未完成」而非普通失败;同时新增的 CAS 使后续命令必须按新 revision 重试,重试会命中同名 no-op 分支收敛。 - tests/asset_rename.rs:257-263 [maintainability · medium] 已被修复:删除 `RenameLocalProjectAssetFaultStage` 枚举与生产签名上的 `fault` 参数,故障注入改用 `#[cfg(test)]` 的 `.agent/runtime/test-fail-next-asset-rename-manifest-write` 标记文件(与 agent_db 既有约定一致),生产 API 面不再能被推上「只回滚、不写 manifest」的路径。 - manifest.rs:1386 [bug · medium] 已被修复:预览缓存失效点下沉到唯一的装盘公共体 `write_manifest_locked_with_version_guard`(install 成功后立即失效,含安装后回读不一致的失败路径),`mutate_manifest_at_allowing_version_removals` 与绑定改写通道一并覆盖;`write_manifest_with_lock_hook` 里的重复调用已移除。 - manifest.rs:1480-1482 [bug · medium] 已被修复:填充侧改为「先取身份快照 → 读取 → 复核快照一致才入缓存」,不再可能出现「旧内容 + 新文件身份」的缓存条目。 - manifest.rs:1135 [security · low] 已被修复:新增 `normalize_manifest_asset_tags`,在共享归一化之后按数量(16)与单标签长度(32 字符)失败关闭;超长标签不截断,避免与共享标签库静默分叉。 - manifest.rs:1180-1181 [bug · medium] 已被修复:审计改为幂等——前后分类与标签完全相同时不追加记录,重试不会再写出 `previousCategory == category` 的假变更审计(保留「manifest 落盘后、推进 revision 前追加」这一既有位置约定)。 - manifest.rs:1140 [maintainability · low] 已被修复:分类写入的项目写锁标签由 `asset.register` 改为 `asset.classification.update`,争用诊断不再误报成素材登记。 - 测试:新增 preview_cache_tests(命中/TOCTOU 不入缓存/身份漂移丢弃/主写入通道显式失效)、分类重复写入不留假审计、标签上下界拒绝;7 条变异验证全部 CAUGHT。
This commit is contained in:
@@ -2043,6 +2043,9 @@ pub(crate) struct DeleteLocalProjectAssetResult {
|
||||
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
|
||||
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS,
|
||||
/// 失败时 manifest 与 revision 都不变。
|
||||
/// - **幂等**:CAS 通过后登记已不在 manifest 里(上一次调用删掉了登记、只在推进 revision
|
||||
/// 时失败)时按 no-op 成功收敛,并照常推进 revision —— 这是「manifest 已提交但 revision
|
||||
/// 未推进」那条中断路径的自愈方式,重试不再报「项目资源不存在」。
|
||||
pub(crate) fn delete_manifest_asset_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
@@ -2091,12 +2094,21 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
.iter()
|
||||
.position(|asset| asset.id == asset_id)
|
||||
else {
|
||||
return Err(format!("项目资源不存在:{asset_id}"));
|
||||
// 幂等收敛:上一次调用可能已经把登记删掉、只在最后推进 revision 时失败
|
||||
// (manifest 已落盘、revision 仍停在旧值)。此时 `expectedProjectId` 与
|
||||
// `expectedProjectRevision` 两项 CAS 都已通过,唯一正确的处置是把它当
|
||||
// no-op 成功 —— 重试再报「项目资源不存在」会让这条命令永远无法自愈。
|
||||
// 收敛路径上 `localPath` 回报空串(前端只用 assetId 与 revision)。
|
||||
return Ok(());
|
||||
};
|
||||
local_path = manifest.assets[index].local_path.clone();
|
||||
manifest.assets.remove(index);
|
||||
if delete_referenced_versions {
|
||||
let referenced_version_ids: Vec<String> =
|
||||
// 引用集合在这里算一次即可;成员判定用 `HashSet`:逐版本 `Vec::contains`
|
||||
// 在引用该素材的版本很多时是 O(V²)。
|
||||
// (版本守卫侧的 `allowed_version_removals` 闭包按约定在写入前另算一次,
|
||||
// 它拿不到这里的可变借用,所以两次扫描无法合并。)
|
||||
let referenced_version_ids: std::collections::HashSet<String> =
|
||||
asset_referencing_versions(manifest, asset_id)
|
||||
.into_iter()
|
||||
.map(|version| version.version_id)
|
||||
|
||||
@@ -1066,14 +1066,21 @@ pub(crate) fn replace_local_project_version_resource(
|
||||
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
|
||||
///
|
||||
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
|
||||
/// 时把文件改回原名,不留半成品。
|
||||
/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId`
|
||||
/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。
|
||||
#[tauri::command]
|
||||
pub(crate) fn rename_local_project_asset(
|
||||
input: RenameLocalProjectAssetInput,
|
||||
) -> Result<RenameLocalProjectAssetResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None)
|
||||
rename_local_project_asset_at(
|
||||
root,
|
||||
&input.expected_project_id,
|
||||
input.expected_project_revision,
|
||||
&input.asset_id,
|
||||
&input.new_file_name,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -6,10 +6,15 @@ use super::filesystem::validate_portable_project_path_component;
|
||||
///
|
||||
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
|
||||
/// 里的 `localPath` 决定,调用方无法指定目标目录。
|
||||
///
|
||||
/// `expectedProjectId` / `expectedProjectRevision` 与删除、分类更新同口径:陈旧客户端
|
||||
/// 不允许凭旧 revision 改写素材文件与 manifest。
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct RenameLocalProjectAssetInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) expected_project_id: String,
|
||||
pub(crate) expected_project_revision: u64,
|
||||
pub(crate) asset_id: String,
|
||||
pub(crate) new_file_name: String,
|
||||
}
|
||||
@@ -22,12 +27,24 @@ pub(crate) struct RenameLocalProjectAssetResult {
|
||||
pub(crate) committed_project_revision: u64,
|
||||
}
|
||||
|
||||
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
|
||||
/// 生产路径始终传 `None`。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RenameLocalProjectAssetFaultStage {
|
||||
/// 文件已改名、manifest 尚未写入时失败。
|
||||
ManifestWrite,
|
||||
/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步
|
||||
/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚。
|
||||
///
|
||||
/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名
|
||||
/// ([`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数
|
||||
/// 推上"只回滚、绝不写 manifest"的那条路。
|
||||
#[cfg(test)]
|
||||
fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> {
|
||||
let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write");
|
||||
match std::fs::read_to_string(&failure_path) {
|
||||
Ok(_) => {
|
||||
std::fs::remove_file(&failure_path)
|
||||
.map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?;
|
||||
Err("fault-injected:rename-asset-manifest-write".to_string())
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验调用方给出的新文件名,返回 trim 后的名字。
|
||||
@@ -64,9 +81,9 @@ fn asset_file_extension(file_name: &str) -> Option<String> {
|
||||
|
||||
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
|
||||
///
|
||||
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名
|
||||
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
|
||||
/// 保持原样,避免改名连带改坏别的引用。
|
||||
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后目录与旧
|
||||
/// 目录等价、文件名与旧名一致(都按所在文件系统的大小写语义比较)。只认真正指向该文件的帧,
|
||||
/// 同目录但指向其它文件的帧保持原样,避免改名连带改坏别的引用。
|
||||
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
|
||||
let image_src = image_src.trim();
|
||||
if image_src.is_empty()
|
||||
@@ -81,7 +98,21 @@ fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_nam
|
||||
Some((directory, file_name)) => (directory, file_name),
|
||||
None => ("", image_src),
|
||||
};
|
||||
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
|
||||
asset_frame_directory_matches(frame_directory, directory)
|
||||
&& frame_file_name.eq_ignore_ascii_case(previous_file_name)
|
||||
}
|
||||
|
||||
/// 目录段的大小写语义必须与所在文件系统一致。
|
||||
///
|
||||
/// 文件名一直按大小写不敏感比较(`Assets/Hero.png` 与 `assets/hero.png` 在 Windows / macOS 上
|
||||
/// 是同一份文件);目录段如果按大小写敏感比较,同一份文件在 `imageSequenceFrames` 里写成
|
||||
/// 不同目录大小写时就对齐不上,改名后留下悬空帧引用。Linux 保持大小写敏感。
|
||||
fn asset_frame_directory_matches(frame_directory: &str, directory: &str) -> bool {
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
frame_directory.eq_ignore_ascii_case(directory)
|
||||
} else {
|
||||
frame_directory == directory
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
|
||||
@@ -125,26 +156,53 @@ fn rollback_asset_file_rename(
|
||||
///
|
||||
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
|
||||
/// 版本数组不可变校验 + 安装后回读一致性校验):
|
||||
/// 0. 与删除 / 分类更新同口径的 CAS:`expectedProjectId` 两段复核 + 当前 revision 比对,
|
||||
/// 失败时磁盘、manifest、revision 全都不动;
|
||||
/// 1. 读 manifest 定位资产,取旧 `localPath`;
|
||||
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
|
||||
/// 3. 更新 manifest 的 `localPath`(`id` / `kind` / `mediaType` / `source` / `category` / `tags`
|
||||
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
|
||||
/// 4. 写 manifest;
|
||||
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
|
||||
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。
|
||||
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required;
|
||||
/// 6. 第 4 步成功、第 6 步(推进 revision)失败时**不回滚**:文件与 manifest 都已提交,
|
||||
/// 这是已提交但对账未完成的状态,按 `reconciliation-required:` 前缀上报,不按普通失败
|
||||
/// 处理(普通失败会诱导调用方"重试一次",把已提交的改名再走一遍)。
|
||||
/// 此时磁盘与 manifest 都在**新**名字上,同名重试会命中上面的 no-op 分支直接成功,
|
||||
/// 所以调用方按"已经改名成功"刷新即可收敛。
|
||||
pub(crate) fn rename_local_project_asset_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_project_revision: u64,
|
||||
asset_id: &str,
|
||||
new_file_name: &str,
|
||||
fault: Option<RenameLocalProjectAssetFaultStage>,
|
||||
) -> Result<RenameLocalProjectAssetResult, String> {
|
||||
let asset_id = asset_id.trim();
|
||||
if asset_id.is_empty() {
|
||||
return Err("素材重命名 assetId 不能为空".to_string());
|
||||
}
|
||||
let new_file_name = normalize_asset_file_name(new_file_name)?;
|
||||
if expected_project_revision
|
||||
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
|
||||
{
|
||||
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
|
||||
}
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("素材重命名 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
|
||||
{
|
||||
return Err("project-revision-conflict".to_string());
|
||||
}
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let mut manifest = read_existing_manifest_for_project(root)?;
|
||||
let index = manifest
|
||||
@@ -232,12 +290,13 @@ pub(crate) fn rename_local_project_asset_at(
|
||||
let asset = manifest.assets[index].clone();
|
||||
|
||||
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
|
||||
let write_error = match fault {
|
||||
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
|
||||
Some("fault-injected:rename-asset-manifest-write".to_string())
|
||||
}
|
||||
None => write_manifest(&manifest_path, &manifest).err(),
|
||||
};
|
||||
// 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。
|
||||
#[cfg(test)]
|
||||
let injected_write_error = take_rename_manifest_write_failure_injection(root).err();
|
||||
#[cfg(not(test))]
|
||||
let injected_write_error: Option<String> = None;
|
||||
let write_error =
|
||||
injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err());
|
||||
if let Some(error) = write_error {
|
||||
return Err(rollback_asset_file_rename(
|
||||
¤t_absolute,
|
||||
@@ -246,8 +305,13 @@ pub(crate) fn rename_local_project_asset_at(
|
||||
));
|
||||
}
|
||||
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root).map_err(
|
||||
|error| {
|
||||
format!(
|
||||
"reconciliation-required: 素材已改名为 {next_local_path} 且 manifest 已落盘,但项目 revision 未能推进:{error}"
|
||||
)
|
||||
},
|
||||
)?;
|
||||
Ok(RenameLocalProjectAssetResult {
|
||||
asset,
|
||||
previous_local_path,
|
||||
|
||||
@@ -1108,7 +1108,8 @@ pub(crate) struct UpdateLocalProjectResourceClassificationResult {
|
||||
|
||||
/// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。
|
||||
/// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生;
|
||||
/// `tags` 按既有归一化语义 trim、去空、去重。
|
||||
/// `tags` 按既有归一化语义 trim、去空、去重,并在此处再按数量与单标签长度收口:
|
||||
/// manifest 会被整份读、整份解析、整份重写,无界标签等于让后续每一次读写都为它付费。
|
||||
pub(crate) fn update_manifest_asset_classification_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
@@ -1132,12 +1133,12 @@ pub(crate) fn update_manifest_asset_classification_at(
|
||||
}
|
||||
let category = game_creation_app_asset_category_from_str(category)
|
||||
.ok_or_else(|| format!("非法资源分类:{}", category.trim()))?;
|
||||
let tags = normalize_game_creation_app_asset_tags(&tags);
|
||||
let tags = normalize_manifest_asset_tags(&tags)?;
|
||||
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "asset.register")?;
|
||||
let _lock = acquire_project_write_lock(root, "asset.classification.update")?;
|
||||
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
|
||||
return Err("project-identity-conflict".to_string());
|
||||
}
|
||||
@@ -1164,19 +1165,26 @@ pub(crate) fn update_manifest_asset_classification_at(
|
||||
// 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。
|
||||
// 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进
|
||||
// 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "asset.classification.update",
|
||||
"assetId": asset.id,
|
||||
"expectedProjectRevision": expected_project_revision,
|
||||
"previousCategory": previous_category,
|
||||
"previousTags": previous_tags,
|
||||
"category": asset.category,
|
||||
"tags": asset.tags,
|
||||
}),
|
||||
)
|
||||
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
|
||||
//
|
||||
// 这条位置约定的代价是"重试会走到同一个值":上次调用可能已经写进同一份分类、只在推进
|
||||
// revision 时失败(revision 未变,所以重试会再次通过 CAS 并重新落到同一个值)。前后值完全
|
||||
// 相同的记录不是"从 X 改成 Y",重复追加只会污染这份审计要保住的语义,因此跳过。
|
||||
let classification_changed = previous_category != asset.category || previous_tags != asset.tags;
|
||||
if classification_changed {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "asset.classification.update",
|
||||
"assetId": asset.id,
|
||||
"expectedProjectRevision": expected_project_revision,
|
||||
"previousCategory": previous_category,
|
||||
"previousTags": previous_tags,
|
||||
"category": asset.category,
|
||||
"tags": asset.tags,
|
||||
}),
|
||||
)
|
||||
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
|
||||
}
|
||||
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
|
||||
.map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?;
|
||||
Ok(UpdateLocalProjectResourceClassificationResult {
|
||||
@@ -1333,6 +1341,32 @@ fn normalize_manifest_task_id_list(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析,
|
||||
/// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。
|
||||
const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16;
|
||||
const ASSET_CLASSIFICATION_MAX_TAG_CHARS: usize = 32;
|
||||
|
||||
/// 复用共享契约的归一化(trim / 去空 / 去重),再按 manifest 自己的持久化上界失败关闭。
|
||||
///
|
||||
/// 这里刻意不做"超长就截断":截断后的标签与共享标签库里的同一条不再相等,
|
||||
/// 用户看到的标签和实际存下来的标签会静默分叉,问题只是从"文件变大"换成"标签对不上"。
|
||||
fn normalize_manifest_asset_tags(tags: &[String]) -> Result<Vec<String>, String> {
|
||||
let normalized = normalize_game_creation_app_asset_tags(tags);
|
||||
for tag in &normalized {
|
||||
if tag.chars().count() > ASSET_CLASSIFICATION_MAX_TAG_CHARS {
|
||||
return Err(format!(
|
||||
"单个资源标签不能超过 {ASSET_CLASSIFICATION_MAX_TAG_CHARS} 个字符"
|
||||
));
|
||||
}
|
||||
}
|
||||
if normalized.len() > ASSET_CLASSIFICATION_MAX_TAGS {
|
||||
return Err(format!(
|
||||
"资源标签最多支持 {ASSET_CLASSIFICATION_MAX_TAGS} 个"
|
||||
));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn read_or_create_manifest(
|
||||
root: &Path,
|
||||
) -> Result<(PathBuf, GameCreationAppManifest), String> {
|
||||
@@ -1556,14 +1590,20 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> {
|
||||
/// 这条缓存只服务「这个资源是否已登记」这一层判断;它不放松 `file.read` 权限、项目边界、
|
||||
/// 敏感路径、普通文件或读取漂移门禁 —— 那些门禁分别在 [`crate::commands::read_local_project_image_preview_at`]
|
||||
/// 与真实字节读取路径上执行,且真实读取从不经过本缓存。
|
||||
///
|
||||
/// 填充侧同样是"快照 → 读取 → 复核快照":读取前后文件身份一致才入缓存。
|
||||
pub(crate) fn read_manifest_cached_for_preview(
|
||||
path: &Path,
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
if let Some(cached) = cached_preview_manifest_if_unchanged(path) {
|
||||
return Ok(cached);
|
||||
}
|
||||
// **先取身份快照,再读内容**:反过来(先读内容、后取身份)时,读取与取身份之间的一次
|
||||
// 并发替换会让缓存里出现"旧内容 + 新文件身份",此后每个读取都会通过身份复核并一直返回
|
||||
// 旧 manifest,直到下一次显式失效。读完复核一次身份,只有两次快照一致才允许入缓存。
|
||||
let before = preview_manifest_snapshot(path);
|
||||
let manifest = read_manifest(path)?;
|
||||
remember_preview_manifest(path, &manifest);
|
||||
remember_preview_manifest_if_stable(path, before, &manifest);
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -1575,6 +1615,16 @@ struct CachedPreviewManifest {
|
||||
manifest: GameCreationAppManifest,
|
||||
}
|
||||
|
||||
/// 缓存命中判据里"这一份文件此刻是什么"的快照:长度 + 修改时间 + 句柄身份。
|
||||
///
|
||||
/// 阅读侧与写入侧共用同一个快照函数,保证"缓存的内容"与"快照的身份"永远是同一时刻取的。
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PreviewManifestSnapshot {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
identity: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// 缓存容量只需覆盖「同时打开的项目数」,取 8 已远大于实际并发。
|
||||
const PREVIEW_MANIFEST_CACHE_LIMIT: usize = 8;
|
||||
|
||||
@@ -1596,8 +1646,9 @@ fn forget_preview_manifest(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
/// 取当前文件身份快照;路径不是普通文件(缺失、符号链接、目录、reparse point)时返回 `None`,
|
||||
/// 表示"这份文件此刻不可缓存"。
|
||||
fn preview_manifest_snapshot(path: &Path) -> Option<PreviewManifestSnapshot> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return None;
|
||||
@@ -1605,17 +1656,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
|
||||
if metadata_is_windows_reparse_point(&metadata) {
|
||||
return None;
|
||||
}
|
||||
let len = metadata.len();
|
||||
let modified = metadata.modified().ok();
|
||||
let identity = fs::File::open(path)
|
||||
.ok()
|
||||
.and_then(|file| open_file_identity_key(&file).ok());
|
||||
Some(PreviewManifestSnapshot {
|
||||
len: metadata.len(),
|
||||
modified: metadata.modified().ok(),
|
||||
identity: fs::File::open(path)
|
||||
.ok()
|
||||
.and_then(|file| open_file_identity_key(&file).ok()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppManifest> {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
let snapshot = preview_manifest_snapshot(path)?;
|
||||
let mut cache = preview_manifest_cache().lock().ok()?;
|
||||
let index = cache
|
||||
.iter()
|
||||
.position(|(cached_key, _)| cached_key == &key)?;
|
||||
let cached = &cache[index].1;
|
||||
if cached.len != len || cached.modified != modified || cached.identity != identity {
|
||||
if cached.len != snapshot.len
|
||||
|| cached.modified != snapshot.modified
|
||||
|| cached.identity != snapshot.identity
|
||||
{
|
||||
cache.remove(index);
|
||||
return None;
|
||||
}
|
||||
@@ -1626,17 +1687,27 @@ fn cached_preview_manifest_if_unchanged(path: &Path) -> Option<GameCreationAppMa
|
||||
Some(manifest)
|
||||
}
|
||||
|
||||
fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
/// 读取前后两次快照一致才入缓存:不一致说明读取期间文件被替换过,`manifest` 未必对应当前文件。
|
||||
fn remember_preview_manifest_if_stable(
|
||||
path: &Path,
|
||||
before: Option<PreviewManifestSnapshot>,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) {
|
||||
let Some(before) = before else {
|
||||
return;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
if preview_manifest_snapshot(path).as_ref() != Some(&before) {
|
||||
return;
|
||||
}
|
||||
let identity = fs::File::open(path)
|
||||
.ok()
|
||||
.and_then(|file| open_file_identity_key(&file).ok());
|
||||
remember_preview_manifest(path, before, manifest);
|
||||
}
|
||||
|
||||
fn remember_preview_manifest(
|
||||
path: &Path,
|
||||
snapshot: PreviewManifestSnapshot,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
let Ok(mut cache) = preview_manifest_cache().lock() else {
|
||||
return;
|
||||
};
|
||||
@@ -1646,9 +1717,9 @@ fn remember_preview_manifest(path: &Path, manifest: &GameCreationAppManifest) {
|
||||
cache.push_back((
|
||||
key,
|
||||
CachedPreviewManifest {
|
||||
len: metadata.len(),
|
||||
modified: metadata.modified().ok(),
|
||||
identity,
|
||||
len: snapshot.len,
|
||||
modified: snapshot.modified,
|
||||
identity: snapshot.identity,
|
||||
manifest: manifest.clone(),
|
||||
},
|
||||
));
|
||||
@@ -1841,11 +1912,9 @@ where
|
||||
}
|
||||
let _write_lock = acquire_manifest_write_lock(path)?;
|
||||
after_lock();
|
||||
let result = write_manifest_locked(path, manifest, allowed_version_removals);
|
||||
if result.is_ok() {
|
||||
forget_preview_manifest(path);
|
||||
}
|
||||
result
|
||||
// 预览缓存的失效放在装盘公共体里(见 `write_manifest_locked_with_version_guard`),
|
||||
// 这里不再单独调用一次:所有写入通道必须共用同一个失效点。
|
||||
write_manifest_locked(path, manifest, allowed_version_removals)
|
||||
}
|
||||
|
||||
fn write_manifest_locked(
|
||||
@@ -1915,6 +1984,14 @@ fn write_manifest_locked_with_version_guard(
|
||||
"manifest 临时文件",
|
||||
)?;
|
||||
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?;
|
||||
// 文件已经被替换:从这一刻起缓存里的内容与磁盘不再对应,必须失效。
|
||||
//
|
||||
// 失效点放在这个唯一的装盘公共体里,所有写入通道(`write_manifest`、`mutate_manifest_at`、
|
||||
// `mutate_manifest_at_allowing_version_removals`、`mutate_manifest_allowing_version_binding_rewrites`)
|
||||
// 都覆盖到;命中判据只有长度 + mtime + 文件身份,兜不住同一 mtime 粒度内的同长度原地改写,
|
||||
// 而 Windows 上某些卷的文件身份又是恒等常量。失败路径(安装后回读不一致)同样要失效 ——
|
||||
// 那时文件也已经换过了,留着旧缓存才是错的。
|
||||
forget_preview_manifest(path);
|
||||
prepare_game_creator_private_path_for_read(path, false, "manifest")?;
|
||||
let installed = read_manifest(path)?;
|
||||
if installed != *manifest {
|
||||
@@ -1965,6 +2042,8 @@ mod classification_tests;
|
||||
#[cfg(test)]
|
||||
mod import_tests;
|
||||
#[cfg(test)]
|
||||
mod preview_cache_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod version_binding_rewrite_tests;
|
||||
|
||||
@@ -420,6 +420,106 @@ fn asset_classification_rejections_do_not_append_agent_db_audit_records() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 审计只记「真的改了什么」:把同一个分类与同一组标签再写一次不留记录。
|
||||
///
|
||||
/// 这条同时是「上一次已写入、只在推进 revision 时失败」那种中断态的重试语义:
|
||||
/// 那次重试会重新落到同一个值,如果照旧追加,审计里就会出现一条
|
||||
/// `previousCategory == category` 的假变更记录。
|
||||
#[test]
|
||||
fn asset_classification_repeat_write_does_not_append_a_noop_audit_record() {
|
||||
let (root, asset_id) = project_with_two_assets("audit-idempotent");
|
||||
let project_id = read_existing_manifest_for_project(&root)
|
||||
.expect("read manifest")
|
||||
.project_id;
|
||||
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read revision before")
|
||||
.revision;
|
||||
|
||||
let first = update_manifest_asset_classification_at(
|
||||
&root,
|
||||
&project_id,
|
||||
revision_before,
|
||||
&asset_id,
|
||||
"scene",
|
||||
vec!["主舞台".to_string()],
|
||||
)
|
||||
.expect("first classification update");
|
||||
assert_eq!(first.committed_project_revision, revision_before + 1);
|
||||
assert_eq!(classification_audit_records(&root).len(), 1);
|
||||
|
||||
let repeat = update_manifest_asset_classification_at(
|
||||
&root,
|
||||
&project_id,
|
||||
first.committed_project_revision,
|
||||
&asset_id,
|
||||
"scene",
|
||||
vec!["主舞台".to_string()],
|
||||
)
|
||||
.expect("repeating the same classification must succeed");
|
||||
assert_eq!(repeat.committed_project_revision, revision_before + 2);
|
||||
assert_eq!(repeat.asset.category, GameCreationAppAssetCategory::Scene);
|
||||
assert_eq!(repeat.asset.tags, vec!["主舞台"]);
|
||||
assert_eq!(
|
||||
classification_audit_records(&root).len(),
|
||||
1,
|
||||
"同一个值重复写入不得追加假变更审计"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 标签必须在写入前有界:数量与单标签长度都要拒绝,manifest 与 revision 都不动。
|
||||
#[test]
|
||||
fn asset_classification_rejects_unbounded_tags() {
|
||||
let (root, asset_id) = project_with_two_assets("tag-bounds");
|
||||
let project_id = read_existing_manifest_for_project(&root)
|
||||
.expect("read manifest")
|
||||
.project_id;
|
||||
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read revision before")
|
||||
.revision;
|
||||
let manifest_before = read_existing_manifest_for_project(&root).expect("read manifest before");
|
||||
|
||||
let too_many = (0..17)
|
||||
.map(|index| format!("标签{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
let many_error = update_manifest_asset_classification_at(
|
||||
&root,
|
||||
&project_id,
|
||||
revision_before,
|
||||
&asset_id,
|
||||
"scene",
|
||||
too_many,
|
||||
)
|
||||
.expect_err("too many tags must be rejected");
|
||||
assert!(many_error.contains("最多支持"), "unexpected: {many_error}");
|
||||
|
||||
let too_long = vec!["像".repeat(33)];
|
||||
let long_error = update_manifest_asset_classification_at(
|
||||
&root,
|
||||
&project_id,
|
||||
revision_before,
|
||||
&asset_id,
|
||||
"scene",
|
||||
too_long,
|
||||
)
|
||||
.expect_err("an over-long tag must be rejected");
|
||||
assert!(long_error.contains("不能超过"), "unexpected: {long_error}");
|
||||
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(&root).expect("read manifest after"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read revision after")
|
||||
.revision,
|
||||
revision_before
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错,
|
||||
/// 而且不能留下一条假的审计。
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
use super::*;
|
||||
|
||||
/// 独立的临时项目根:缓存是进程内全局状态,按路径做键,所以每个用例必须用自己的路径。
|
||||
fn preview_cache_test_root(test_name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"genarrative-manifest-preview-cache-{test_name}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
fn preview_cache_manifest_path(root: &Path) -> PathBuf {
|
||||
let path = root.join(".agent/manifest.json");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create preview cache manifest directory");
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// 直接落盘一份 manifest 内容(不走 `write_manifest`,避免它自己带来的缓存失效干扰断言)。
|
||||
fn install_preview_cache_manifest(path: &Path, manifest: &GameCreationAppManifest) {
|
||||
let payload = serde_json::to_string_pretty(manifest).expect("serialize preview cache manifest");
|
||||
fs::write(path, format!("{payload}\n")).expect("install preview cache manifest");
|
||||
}
|
||||
|
||||
fn preview_cache_entry_count(path: &Path) -> usize {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
preview_manifest_cache()
|
||||
.lock()
|
||||
.expect("lock preview manifest cache")
|
||||
.iter()
|
||||
.filter(|(cached_key, _)| cached_key == &key)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 读取前后身份一致时正常入缓存并命中。
|
||||
#[test]
|
||||
fn preview_cache_serves_a_repeated_read_from_the_snapshot() {
|
||||
let root = preview_cache_test_root("hit");
|
||||
let path = preview_cache_manifest_path(&root);
|
||||
let manifest = new_game_creation_app_manifest("preview-cache-project", "预览缓存项目");
|
||||
install_preview_cache_manifest(&path, &manifest);
|
||||
|
||||
let first = read_manifest_cached_for_preview(&path).expect("first cached read");
|
||||
assert_eq!(first.project_id, "preview-cache-project");
|
||||
assert_eq!(preview_cache_entry_count(&path), 1);
|
||||
|
||||
let second = read_manifest_cached_for_preview(&path).expect("second cached read");
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(preview_cache_entry_count(&path), 1);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 读取期间文件被并发替换(快照对不上)时不得入缓存:
|
||||
/// 否则缓存里会出现"旧内容 + 新文件身份",之后每个读取都会通过身份复核并一直返回旧内容。
|
||||
#[test]
|
||||
fn preview_cache_skips_insertion_when_the_file_changed_during_the_read() {
|
||||
let root = preview_cache_test_root("toc-tou");
|
||||
let path = preview_cache_manifest_path(&root);
|
||||
let stale = new_game_creation_app_manifest("preview-cache-stale", "旧内容");
|
||||
let fresh = new_game_creation_app_manifest("preview-cache-fresh", "新内容");
|
||||
install_preview_cache_manifest(&path, &stale);
|
||||
|
||||
// 读之前取快照 ⇒ 这里模拟"读完之后、入缓存之前"文件被并发写者替换。
|
||||
// 生产写入是"临时文件 + rename",所以这里也用 rename:原地 `fs::write` 改写不会换文件身份,
|
||||
// 长度与 mtime 又可能落在同一粒度内,快照根本区分不出来(那正是写入侧必须显式失效的原因)。
|
||||
let before = preview_manifest_snapshot(&path).expect("snapshot before read");
|
||||
let replacement_path = path.with_file_name(".manifest.json.concurrent");
|
||||
install_preview_cache_manifest(&replacement_path, &fresh);
|
||||
fs::rename(&replacement_path, &path).expect("replace manifest concurrently");
|
||||
remember_preview_manifest_if_stable(&path, Some(before), &stale);
|
||||
|
||||
assert_eq!(
|
||||
preview_cache_entry_count(&path),
|
||||
0,
|
||||
"读取期间文件变化时不得写入缓存"
|
||||
);
|
||||
let read = read_manifest_cached_for_preview(&path).expect("read after replacement");
|
||||
assert_eq!(read.project_id, "preview-cache-fresh");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 缓存命中判据里"文件身份"这一维必须真的在起作用:
|
||||
/// 用同长度内容 + 原封不动的 mtime 走 rename 替换文件(长度与修改时间都对得上,只有身份变了),
|
||||
/// 旧条目必须被丢弃并重新读取,否则缓存会一直返回替换前的 manifest。
|
||||
#[test]
|
||||
fn preview_cache_drops_entries_whose_file_identity_changed() {
|
||||
let root = preview_cache_test_root("identity-drift");
|
||||
let path = preview_cache_manifest_path(&root);
|
||||
let stale = new_game_creation_app_manifest("preview-cache-before", "替换前");
|
||||
let fresh = new_game_creation_app_manifest("preview-cache-after-", "替换后");
|
||||
install_preview_cache_manifest(&path, &stale);
|
||||
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
|
||||
assert_eq!(cached.project_id, "preview-cache-before");
|
||||
|
||||
let original = fs::metadata(&path).expect("original manifest metadata");
|
||||
let replacement_path = path.with_file_name(".manifest.json.replacement");
|
||||
install_preview_cache_manifest(&replacement_path, &fresh);
|
||||
assert_eq!(
|
||||
fs::metadata(&replacement_path)
|
||||
.expect("replacement metadata")
|
||||
.len(),
|
||||
original.len(),
|
||||
"这条用例要求替换内容与原内容同长度"
|
||||
);
|
||||
let replacement = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&replacement_path)
|
||||
.expect("open replacement manifest");
|
||||
replacement
|
||||
.set_times(fs::FileTimes::new().set_modified(original.modified().expect("original mtime")))
|
||||
.expect("restore replacement mtime");
|
||||
drop(replacement);
|
||||
fs::rename(&replacement_path, &path).expect("replace manifest by rename");
|
||||
|
||||
let read = read_manifest_cached_for_preview(&path).expect("read after external replacement");
|
||||
assert_eq!(
|
||||
read.project_id, "preview-cache-after-",
|
||||
"长度与修改时间都一致时,只有文件身份能拦住外部替换后的旧缓存"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 主写入通道必须让预览缓存失效:`mutate_manifest_at_allowing_version_removals`
|
||||
/// (素材登记 / 删除 / 分类更新 / 版本增删)走的是 `write_manifest_locked`,
|
||||
/// 曾经是唯一不调用 `forget_preview_manifest` 的写入路径。
|
||||
#[test]
|
||||
fn mutate_manifest_at_allowing_version_removals_invalidates_the_preview_cache() {
|
||||
let root = preview_cache_test_root("invalidate");
|
||||
let path = preview_cache_manifest_path(&root);
|
||||
let mut manifest = new_game_creation_app_manifest("preview-cache-mutate", "原始项目名");
|
||||
manifest.goal = Some("原始目标".to_string());
|
||||
install_preview_cache_manifest(&path, &manifest);
|
||||
let cached = read_manifest_cached_for_preview(&path).expect("prime the cache");
|
||||
assert_eq!(cached.goal.as_deref(), Some("原始目标"));
|
||||
assert_eq!(preview_cache_entry_count(&path), 1);
|
||||
|
||||
// 同长度原地改写:命中判据里的长度不变,只有显式失效能保证不返回旧内容。
|
||||
mutate_manifest_at_allowing_version_removals(&root, &no_version_removals, |manifest| {
|
||||
manifest.goal = Some("新目标值".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.expect("mutate manifest");
|
||||
|
||||
assert_eq!(
|
||||
preview_cache_entry_count(&path),
|
||||
0,
|
||||
"写入通道必须显式失效预览缓存"
|
||||
);
|
||||
|
||||
let read = read_manifest_cached_for_preview(&path).expect("read after mutation");
|
||||
assert_eq!(
|
||||
read.goal.as_deref(),
|
||||
Some("新目标值"),
|
||||
"写入通道必须显式失效预览缓存"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
@@ -354,7 +354,8 @@ fn version_removal_stays_rejected_outside_the_confirmed_asset_delete() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 删除同样走 CAS:陈旧 revision、跨项目身份与不存在的素材都被拒绝,manifest 与 revision 不变。
|
||||
/// 删除同样走 CAS:陈旧 revision、跨项目身份都被拒绝,manifest 与 revision 不变;
|
||||
/// 登记已经不在 manifest 里时按幂等 no-op 成功收敛(见下一条用例)。
|
||||
#[test]
|
||||
fn asset_delete_enforces_revision_and_identity_cas() {
|
||||
let root = asset_delete_project_fixture();
|
||||
@@ -371,14 +372,6 @@ fn asset_delete_enforces_revision_and_identity_cas() {
|
||||
.expect_err("cross-project identity must be rejected");
|
||||
assert_eq!(identity_error, "project-identity-conflict");
|
||||
|
||||
let missing_error =
|
||||
delete_manifest_asset_at(&root, "project-1", revision_before, "absent", true)
|
||||
.expect_err("missing asset must be rejected");
|
||||
assert!(
|
||||
missing_error.contains("项目资源不存在"),
|
||||
"unexpected error: {missing_error}"
|
||||
);
|
||||
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes");
|
||||
assert!(
|
||||
manifest.assets.iter().any(|asset| asset.id == asset_id),
|
||||
@@ -392,3 +385,39 @@ fn asset_delete_enforces_revision_and_identity_cas() {
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 「manifest 已提交、revision 未推进」那条中断路径必须能自愈:
|
||||
/// 用同一个 `expectedProjectRevision` 重试要按 no-op 成功收敛,并补推进 revision。
|
||||
#[test]
|
||||
fn asset_delete_retry_converges_when_only_the_revision_advance_failed() {
|
||||
let root = asset_delete_project_fixture();
|
||||
let asset_id = register_asset_delete_fixture_asset(&root, "assets/hero.png", "art-asset-plan");
|
||||
let revision_before = project_revision(&root);
|
||||
|
||||
delete_asset_at_current_revision(&root, &asset_id, false).expect("first delete commits");
|
||||
let committed = project_revision(&root);
|
||||
assert_eq!(committed, revision_before + 1);
|
||||
|
||||
// 复现中断态:登记已经删掉,但 revision 被写回旧值(等价于当年推进失败)。
|
||||
let mut revision = read_game_creator_agent_runtime_project_revision(&root).expect("read");
|
||||
revision.revision = revision_before;
|
||||
write_game_creator_agent_runtime_project_revision(&root, &revision).expect("rewind revision");
|
||||
|
||||
let retry = delete_manifest_asset_at(&root, "project-1", revision_before, &asset_id, false)
|
||||
.expect("retry must converge instead of reporting a missing asset");
|
||||
assert_eq!(retry.asset_id, asset_id);
|
||||
assert_eq!(retry.committed_project_revision, revision_before + 1);
|
||||
assert!(retry.file_retained);
|
||||
assert_eq!(
|
||||
project_revision(&root),
|
||||
revision_before + 1,
|
||||
"收敛路径必须补推进 revision,否则工作区会停在旧 revision"
|
||||
);
|
||||
let manifest = read_manifest_for_project(&root).expect("read manifest after convergent retry");
|
||||
assert!(
|
||||
!manifest.assets.iter().any(|asset| asset.id == asset_id),
|
||||
"收敛路径不得把登记写回去"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,21 @@ fn asset_rename_revision(root: &Path) -> u64 {
|
||||
.revision
|
||||
}
|
||||
|
||||
/// 用当前 revision 发起重命名:模拟前端的正常调用(CAS 要求调用方带上它读到的 revision)。
|
||||
fn rename_asset_at_current_revision(
|
||||
root: &Path,
|
||||
asset_id: &str,
|
||||
new_file_name: &str,
|
||||
) -> Result<RenameLocalProjectAssetResult, String> {
|
||||
rename_local_project_asset_at(
|
||||
root,
|
||||
"project-1",
|
||||
asset_rename_revision(root),
|
||||
asset_id,
|
||||
new_file_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// 成功改名:磁盘文件真的换了名字,manifest 只改 `localPath`,资产 `id` 与其余字段不变,
|
||||
/// 项目 revision 前进一格。
|
||||
#[test]
|
||||
@@ -56,8 +71,8 @@ fn rename_local_project_asset_renames_file_and_keeps_asset_identity() {
|
||||
let before = asset_rename_entry(&root, &asset_id);
|
||||
let revision_before = asset_rename_revision(&root);
|
||||
|
||||
let result = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
|
||||
.expect("rename asset");
|
||||
let result =
|
||||
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
|
||||
|
||||
assert_eq!(result.previous_local_path, "assets/hero.png");
|
||||
assert_eq!(result.asset.local_path, "assets/hero-idle.png");
|
||||
@@ -93,7 +108,7 @@ fn rename_local_project_asset_is_a_noop_for_the_same_file_name() {
|
||||
let revision_before = asset_rename_revision(&root);
|
||||
|
||||
let result =
|
||||
rename_local_project_asset_at(&root, &asset_id, "hero.png", None).expect("no-op rename");
|
||||
rename_asset_at_current_revision(&root, &asset_id, "hero.png").expect("no-op rename");
|
||||
|
||||
assert_eq!(result.previous_local_path, "assets/hero.png");
|
||||
assert_eq!(result.asset.local_path, "assets/hero.png");
|
||||
@@ -125,7 +140,7 @@ fn rename_local_project_asset_rejects_unsafe_file_names() {
|
||||
"..png",
|
||||
"assets/../hero.png",
|
||||
] {
|
||||
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, name)
|
||||
.expect_err("unsafe file name must be rejected");
|
||||
assert!(
|
||||
error.contains("不能为空") || error.contains("路径分隔符") || error.contains(".."),
|
||||
@@ -148,7 +163,7 @@ fn rename_local_project_asset_rejects_cross_directory_targets() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
fs::create_dir_all(root.join("assets/nested")).expect("nested dir");
|
||||
|
||||
let error = rename_local_project_asset_at(&root, &asset_id, "nested/hero.png", None)
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, "nested/hero.png")
|
||||
.expect_err("cross directory rename must be rejected");
|
||||
assert!(error.contains("路径分隔符"), "意外错误:{error}");
|
||||
|
||||
@@ -168,7 +183,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
|
||||
for name in ["hero.jpg", "hero", "hero.png.bak"] {
|
||||
let error = rename_local_project_asset_at(&root, &asset_id, name, None)
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, name)
|
||||
.expect_err("extension change must be rejected");
|
||||
assert!(error.contains("扩展名"), "意外错误:{error}");
|
||||
}
|
||||
@@ -183,7 +198,7 @@ fn rename_local_project_asset_rejects_extension_changes() {
|
||||
asset_rename_source(),
|
||||
)
|
||||
.expect("register extensionless asset");
|
||||
let error = rename_local_project_asset_at(&root, &extensionless.id, "notes.txt", None)
|
||||
let error = rename_asset_at_current_revision(&root, &extensionless.id, "notes.txt")
|
||||
.expect_err("adding an extension must be rejected");
|
||||
assert!(error.contains("扩展名"), "意外错误:{error}");
|
||||
|
||||
@@ -205,7 +220,7 @@ fn rename_local_project_asset_rejects_existing_target_name() {
|
||||
.expect("other asset file");
|
||||
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
|
||||
|
||||
let error = rename_local_project_asset_at(&root, &asset_id, "other.png", None)
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, "other.png")
|
||||
.expect_err("existing target must be rejected");
|
||||
assert!(error.contains("同名文件"), "意外错误:{error}");
|
||||
|
||||
@@ -230,12 +245,12 @@ fn rename_local_project_asset_rejects_existing_target_name() {
|
||||
fn rename_local_project_asset_requires_an_existing_asset_and_file() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
|
||||
let error = rename_local_project_asset_at(&root, "missing-asset", "hero-idle.png", None)
|
||||
let error = rename_asset_at_current_revision(&root, "missing-asset", "hero-idle.png")
|
||||
.expect_err("unknown asset must be rejected");
|
||||
assert!(error.contains("项目资源不存在"), "意外错误:{error}");
|
||||
|
||||
fs::remove_file(root.join("assets/hero.png")).expect("remove asset file");
|
||||
let error = rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None)
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
|
||||
.expect_err("missing file must be rejected");
|
||||
assert!(error.contains("素材文件不存在"), "意外错误:{error}");
|
||||
assert!(!root.join("assets/hero-idle.png").exists());
|
||||
@@ -253,14 +268,14 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
|
||||
let revision_before = asset_rename_revision(&root);
|
||||
|
||||
let error = rename_local_project_asset_at(
|
||||
&root,
|
||||
&asset_id,
|
||||
"hero-idle.png",
|
||||
Some(RenameLocalProjectAssetFaultStage::ManifestWrite),
|
||||
fs::write(
|
||||
root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"),
|
||||
"注入下一次 manifest 写失败",
|
||||
)
|
||||
.expect_err("injected manifest write failure must surface");
|
||||
.expect("write rename manifest failure injection marker");
|
||||
|
||||
let error = rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png")
|
||||
.expect_err("injected manifest write failure must surface");
|
||||
assert!(error.contains("fault-injected"), "意外错误:{error}");
|
||||
|
||||
assert_eq!(
|
||||
@@ -277,6 +292,101 @@ fn rename_local_project_asset_rolls_back_the_file_when_manifest_write_fails() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 重命名同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,磁盘、manifest 与 revision 都不动。
|
||||
#[test]
|
||||
fn rename_local_project_asset_enforces_revision_and_identity_cas() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
let manifest_before = fs::read(root.join(".agent/manifest.json")).expect("manifest bytes");
|
||||
let revision_before = asset_rename_revision(&root);
|
||||
|
||||
let revision_error = rename_local_project_asset_at(
|
||||
&root,
|
||||
"project-1",
|
||||
revision_before + 1,
|
||||
&asset_id,
|
||||
"hero-idle.png",
|
||||
)
|
||||
.expect_err("stale revision must be rejected");
|
||||
assert_eq!(revision_error, "project-revision-conflict");
|
||||
|
||||
let identity_error = rename_local_project_asset_at(
|
||||
&root,
|
||||
"project-2",
|
||||
revision_before,
|
||||
&asset_id,
|
||||
"hero-idle.png",
|
||||
)
|
||||
.expect_err("cross-project identity must be rejected");
|
||||
assert_eq!(identity_error, "project-identity-conflict");
|
||||
|
||||
let blank_identity_error =
|
||||
rename_local_project_asset_at(&root, " ", revision_before, &asset_id, "hero-idle.png")
|
||||
.expect_err("blank expectedProjectId must be rejected");
|
||||
assert!(
|
||||
blank_identity_error.contains("不能为空"),
|
||||
"{blank_identity_error}"
|
||||
);
|
||||
|
||||
assert!(root.join("assets/hero.png").is_file());
|
||||
assert!(!root.join("assets/hero-idle.png").exists());
|
||||
assert_eq!(
|
||||
fs::read(root.join(".agent/manifest.json")).expect("manifest bytes"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(asset_rename_revision(&root), revision_before);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// `imageSequenceFrames` 的目录段按文件系统大小写语义比较:同一份文件写成别的目录大小写
|
||||
/// 也要被对齐(Windows / macOS 上 `Assets/hero.png` 与 `assets/hero.png` 是同一份文件)。
|
||||
#[test]
|
||||
fn rename_local_project_asset_aligns_frames_with_case_insensitive_directory() {
|
||||
let (root, asset_id) = asset_rename_project_fixture();
|
||||
mutate_manifest_at(&root, |manifest| {
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.expect("asset entry");
|
||||
asset.image_sequence_frames = Some(
|
||||
[
|
||||
("Assets/hero.png", 32u32, 32u32),
|
||||
("other/hero.png", 64, 64),
|
||||
]
|
||||
.into_iter()
|
||||
.map(
|
||||
|(image_src, width, height)| GameCreationAppImageSequenceFrame {
|
||||
image_src: image_src.to_string(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed image sequence frames");
|
||||
|
||||
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
|
||||
|
||||
let frames = asset_rename_entry(&root, &asset_id)
|
||||
.image_sequence_frames
|
||||
.expect("image sequence frames");
|
||||
let expected = if cfg!(any(windows, target_os = "macos")) {
|
||||
"assets/hero-idle.png"
|
||||
} else {
|
||||
"Assets/hero.png"
|
||||
};
|
||||
assert_eq!(frames[0].image_src, expected);
|
||||
// 指向别的目录的帧在任何平台都不动。
|
||||
assert_eq!(frames[1].image_src, "other/hero.png");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// `imageSequenceFrames` 里指向被改名文件的本地帧对齐到新路径,指向别的文件或远程地址的帧不动。
|
||||
#[test]
|
||||
fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file() {
|
||||
@@ -309,7 +419,7 @@ fn rename_local_project_asset_aligns_image_sequence_frames_of_the_renamed_file()
|
||||
})
|
||||
.expect("seed image sequence frames");
|
||||
|
||||
rename_local_project_asset_at(&root, &asset_id, "hero-idle.png", None).expect("rename asset");
|
||||
rename_asset_at_current_revision(&root, &asset_id, "hero-idle.png").expect("rename asset");
|
||||
|
||||
let frames = asset_rename_entry(&root, &asset_id)
|
||||
.image_sequence_frames
|
||||
|
||||
Reference in New Issue
Block a user