修复远程素材文件名碰撞
为原始稳定素材 ID 添加确定性摘要文件名 写入前拒绝批次内重复目标路径 补充碰撞与重复路径测试并同步更新技术方案 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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。本阶段不新增图片像素数限制或命令超时。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user