修复远程素材文件名碰撞

为原始稳定素材 ID 添加确定性摘要文件名
写入前拒绝批次内重复目标路径
补充碰撞与重复路径测试并同步更新技术方案

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-08-20 13:59:56 +08:00
parent b155cd5705
commit b3c70e9e15
2 changed files with 62 additions and 11 deletions
@@ -1,5 +1,7 @@
use super::*;
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
@@ -2320,6 +2322,54 @@ mod ui_editor_font_tests {
}
}
#[cfg(test)]
mod remote_asset_path_tests {
use super::*;
#[test]
fn remote_asset_paths_distinguish_ids_that_sanitize_to_the_same_name() {
let slash_path = remote_asset_local_path("a/b", "png");
let colon_path = remote_asset_local_path("a:b", "png");
assert_ne!(slash_path, colon_path);
assert_eq!(slash_path, remote_asset_local_path("a/b", "png"));
assert!(slash_path.starts_with("assets/uploads/remote-a_b-"));
assert!(slash_path.ends_with(".png"));
}
#[test]
fn duplicate_remote_asset_destination_is_rejected() {
let mut destinations = HashSet::new();
let local_path = remote_asset_local_path("stable-id", "webp");
reserve_remote_asset_destination(&mut destinations, &local_path)
.expect("reserve first destination");
assert_eq!(
reserve_remote_asset_destination(&mut destinations, &local_path)
.expect_err("reject duplicate destination"),
format!("平台素材目标路径重复:{local_path}")
);
}
}
fn remote_asset_local_path(asset_id: &str, extension: &str) -> String {
let readable_id = sanitize_file_name(asset_id);
let identity_digest = format!("{:x}", Sha256::digest(asset_id.as_bytes()));
let short_digest = &identity_digest[..12];
format!("assets/uploads/remote-{readable_id}-{short_digest}.{extension}")
}
fn reserve_remote_asset_destination(
destinations: &mut HashSet<String>,
local_path: &str,
) -> Result<(), String> {
if destinations.insert(local_path.to_string()) {
Ok(())
} else {
Err(format!("平台素材目标路径重复:{local_path}"))
}
}
pub(crate) async fn import_ui_editor_remote_assets(
project_path: String,
assets: Vec<serde_json::Value>,
@@ -2332,6 +2382,7 @@ pub(crate) async fn import_ui_editor_remote_assets(
// IPC 只传稳定引用/签名 URL,由 Rust 流式下载并限制响应体;下载期间不占有项目写锁。
let mut total_size = 0u64;
let mut downloads = Vec::with_capacity(assets.len());
let mut destinations = HashSet::with_capacity(assets.len());
for asset in assets {
let asset_id = json_string_field(&asset, "assetId")
.or_else(|| json_string_field(&asset, "objectKey"))
@@ -2358,21 +2409,21 @@ pub(crate) async fn import_ui_editor_remote_assets(
} else {
return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string());
};
downloads.push((asset, asset_id, media_type.to_string(), bytes));
let extension = infer_file_extension(
json_string_field(&asset, "objectKey")
.or_else(|| json_string_field(&asset, "imageSrc"))
.as_deref(),
media_type,
);
let local_path = remote_asset_local_path(&asset_id, extension);
reserve_remote_asset_destination(&mut destinations, &local_path)?;
downloads.push((asset, asset_id, media_type.to_string(), local_path, bytes));
}
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
// 远程素材导入保持与本地图片/字体相同的增量语义,不对已成功项目文件做整批回滚。
let mut imported = Vec::with_capacity(downloads.len());
for (asset, asset_id, media_type, bytes) in downloads {
let extension = infer_file_extension(
json_string_field(&asset, "objectKey")
.or_else(|| json_string_field(&asset, "imageSrc"))
.as_deref(),
&media_type,
);
let local_name = sanitize_file_name(&asset_id);
let local_path = format!("assets/uploads/remote-{local_name}.{extension}");
for (asset, asset_id, media_type, local_path, bytes) in downloads {
let target = resolve_local_project_path(root, &local_path)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?;
@@ -46,7 +46,7 @@ UI Editor 当前把“识别界面结构”定义为结构草稿阶段,而不
`merge_ui` 的 Tauri 命令参数继续保持 `State`,不新增前端字符串 payload 或 IPC 参数上限。命令进入 Rust 后、构建 LLM 请求前,按每棵源树独立限制 `512` 个节点和 `32` 层,不跨树求和;序列化投影超过 `2 MiB` 时同样拒绝。本边界不宣称覆盖 Tauri 自身的入参反序列化阶段。
UI Editor 的图片、字体和远程素材导入采用增量提交合同:输入先完成各入口已有的预检,随后按顺序逐项写入和登记;前面已成功的项目在后续写入或登记失败时保持有效,调用方必须重新读取 manifest 取得已提交集合。该入口不提供整批事务回滚,也不承诺失败后“零文件、零 manifest 变化”;后续如需原子批量导入,必须新增明确的 transaction / reconciliation 合同,不能把当前入口静默改成另一种语义。
UI Editor 的图片、字体和远程素材导入采用增量提交合同:输入先完成各入口已有的预检,随后按顺序逐项写入和登记;前面已成功的项目在后续写入或登记失败时保持有效,调用方必须重新读取 manifest 取得已提交集合。远程素材目标文件名必须同时包含清洗后的可读 ID 与原始稳定 ID 的确定性摘要,不能让不同 ID 因清洗结果相同而覆盖已有文件;同一批次计算出的重复目标路径必须在写入前拒绝。该入口不提供整批事务回滚,也不承诺失败后“零文件、零 manifest 变化”;后续如需原子批量导入,必须新增明确的 transaction / reconciliation 合同,不能把当前入口静默改成另一种语义。
作为 LLM 参考图使用的 UI 设计图单张文件上限为 `5 MiB`,不设置多图合计大小上限。前端设计图 AssetImporter 对本地和远端入口使用相同单文件限制;结构识别与界面语义建议在 Rust 中先检查文件元数据、再有限读取,并把读取与 base64 编码放在 blocking worker,避免阻塞 Tokio async worker。本阶段不新增图片像素数限制或命令超时。