diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs index dc0a19150..d63fef865 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -2,10 +2,16 @@ use super::*; use std::fs::File; use std::io::{Read, Write}; -/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。 +/// 显式保存:把项目内的素材文件复制到用户选定的目标路径。 /// /// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download` /// 时 `` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。 +/// +/// 允许导出的范围 = "项目根内真实存在的普通文件"减去敏感配置与 Runtime 控制面 +/// (见 [`resolve_export_source_file`])。**不额外要求文件已在 manifest 里登记**: +/// 资源画布里的 DirectProject 附件(`attachment:`)是客户端导入的项目内文件, +/// 用户同样要能把它们另存出来,但它们不是 manifest 资产;按登记与否放行会把这条 +/// 正常路径一并拒掉。素材保存因此与通用项目读取共用同一套路径门禁,而不是更宽的门禁。 const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024; #[derive(Clone, Debug, Deserialize)] @@ -25,10 +31,14 @@ pub(crate) struct SaveLocalProjectAssetFileResult { fn resolve_export_source_file(root: &Path, relative_path: &str) -> Result { let normalized = normalize_relative_path(relative_path.trim())?; - if normalized.is_empty() { - return Err("待保存的素材路径不能为空".to_string()); - } - let source = root.join(&normalized); + // 导出同样是"把项目里的文件交出去",必须与通用项目读取路径同一套拒绝口径: + // 敏感配置(`.env` 等)与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。 + reject_agent_runtime_private_control_path(&normalized)?; + reject_sensitive_project_file_read(&normalized)?; + // 逐段复核符号链接与 Windows reparse point,而不是只看最后一段:`assets` 本身是链接 + // 或 junction 时,`root.join(...)` 仍会解析到项目外,导出就变成任意文件读取。 + // 这里与改名路径共用 `resolve_local_project_path`,保证是同一条边界判据。 + let source = resolve_local_project_path(root, &normalized)?; // 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。 let metadata = std::fs::symlink_metadata(&source) .map_err(|error| format!("素材文件不存在或不可读:{error}"))?; @@ -63,19 +73,83 @@ fn resolve_export_destination_file(destination_path: &str) -> Result Result { - let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - enforce_project_auto_permission_policy(root, "file.read")?; +/// 已存在路径的文件身份;读不到身份、或身份是退化常量时返回 `None`。 +/// +/// FAT/exFAT 与部分网络卷上 `file index` 恒为 0,那不是一个能区分文件的身份, +/// 拿它比较只会把正常导出误判成"自我覆盖",所以这种值一律当成"没有身份"。 +fn export_path_file_identity(path: &Path) -> Option<(u64, u64)> { + let identity = File::open(path) + .ok() + .and_then(|file| open_file_identity_key(&file).ok())?; + if identity.1 == 0 { + None + } else { + Some(identity) + } +} - let source = resolve_export_source_file(root, &input.relative_path)?; - let destination = resolve_export_destination_file(&input.destination_path)?; +/// 路径等价判据:Windows / macOS 的文件系统默认大小写不敏感,同一份文件可能只差大小写。 +fn export_paths_equivalent(left: &Path, right: &Path) -> bool { + if cfg!(any(windows, target_os = "macos")) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} - let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?; +/// 保存目标不能与源素材是同一个文件。 +/// +/// 前端用的是原生保存对话框,用户完全可以导航进项目素材目录并选中源文件本身;`File::create` +/// 会先把目标截断,随后的复制只能读到 0 字节,于是"复制成功"(`byte_len = 0`)返回,而源素材 +/// 已经被就地毁掉。路径等价、以及符号链接 / 硬链接指向同一份文件,都要在创建写句柄之前拦住。 +fn reject_export_destination_matching_source( + source: &Path, + destination: &Path, +) -> Result<(), String> { + if !destination.exists() { + return Ok(()); + } + let same_file = match ( + export_path_file_identity(source), + export_path_file_identity(destination), + ) { + (Some(source_identity), Some(destination_identity)) => { + source_identity == destination_identity + } + _ => false, + }; + let same_path = matches!( + (source.canonicalize(), destination.canonicalize()), + (Ok(source_canonical), Ok(destination_canonical)) + if export_paths_equivalent(&source_canonical, &destination_canonical) + ); + if same_file || same_path { + return Err("保存目标不能与素材源文件相同".to_string()); + } + Ok(()) +} + +/// 复制目标文件名用的临时兄弟路径:必须与目标同目录,`rename` 才是同卷原子替换。 +fn export_destination_temp_path(destination: &Path) -> PathBuf { + let file_name = destination + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("asset-export"); + destination.with_file_name(format!( + ".{file_name}.tmp.{}.{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn copy_export_source_bytes_to(source: &Path, temp_path: &Path) -> Result { + let mut reader = File::open(source).map_err(|error| format!("打开素材文件失败:{error}"))?; let mut writer = - File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?; + File::create(temp_path).map_err(|error| format!("创建保存目标失败:{error}"))?; let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES]; let mut byte_len = 0_u64; loop { @@ -93,6 +167,42 @@ pub(crate) fn save_local_project_asset_file_at( writer .flush() .map_err(|error| format!("刷新保存目标失败:{error}"))?; + // 替换前必须落盘:只 `flush` 的话,崩溃窗口里换过去的可能是还没写完的内容。 + writer + .sync_all() + .map_err(|error| format!("落盘保存目标失败:{error}"))?; + Ok(byte_len) +} + +/// 先整份复制到目标同目录的临时文件,再原子替换目标。 +/// +/// 直接往最终目标写会在 `File::create` 那一刻截断既有文件:复制中途失败就同时留下半截文件 +/// 和"原目标已被毁掉"两个后果。manifest 写入(`write_manifest_locked`)用的是同一套手法。 +fn copy_export_source_to_destination(source: &Path, destination: &Path) -> Result { + let temp_path = export_destination_temp_path(destination); + let result = copy_export_source_bytes_to(source, &temp_path).and_then(|byte_len| { + fs::rename(&temp_path, destination) + .map_err(|error| format!("替换保存目标失败:{}: {error}", destination.display()))?; + Ok(byte_len) + }); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +pub(crate) fn save_local_project_asset_file_at( + input: SaveLocalProjectAssetFileInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + + let source = resolve_export_source_file(root, &input.relative_path)?; + let destination = resolve_export_destination_file(&input.destination_path)?; + reject_export_destination_matching_source(&source, &destination)?; + + let byte_len = copy_export_source_to_destination(&source, &destination)?; Ok(SaveLocalProjectAssetFileResult { destination_path: destination.to_string_lossy().into_owned(), @@ -246,4 +356,165 @@ mod tests { let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&destination_directory); } + + /// 敏感配置与 Runtime 控制面文件都不能被导出到项目外:导出走的是同一条 + /// `file.read` 拒绝口径,而不是只做路径拼接。 + #[test] + fn rejects_sensitive_and_agent_control_files_as_source() { + let (root, assets) = create_export_project(); + std::fs::write(root.join(".env"), "SECRET=1").expect("write .env"); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + let agent_runtime = root.join(".agent/runtime"); + std::fs::create_dir_all(&agent_runtime).expect("create agent runtime dir"); + std::fs::write(agent_runtime.join("run.json"), "{}").expect("write control file"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + for relative_path in [".env", ".agent/runtime/run.json"] { + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: relative_path.to_string(), + destination_path: destination_directory + .join("copy.bin") + .to_string_lossy() + .into_owned(), + }) + .expect_err("sensitive or control-plane source must be rejected"); + assert!( + error.contains("拒绝读取敏感配置文件") || error.contains("私有控制面"), + "意外错误:{error}" + ); + } + assert!(!destination_directory.join("copy.bin").exists()); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 中间目录是符号链接时不能把项目外的文件当素材导出:只查最后一段会漏掉这条逃逸路径。 + #[test] + fn rejects_source_reached_through_an_intermediate_symlink() { + let (root, assets) = create_export_project(); + let outside = unique_asset_export_directory("outside"); + std::fs::create_dir_all(&outside).expect("create outside directory"); + std::fs::write(outside.join("secret.png"), b"outside-secret").expect("write outside file"); + std::fs::remove_dir_all(&assets).expect("remove real assets dir"); + // Windows 上建目录符号链接需要开发者模式或特权;拿不到权限就跳过这条断言, + // 不让环境能力决定测试结论。Unix 上恒可用。 + #[cfg(windows)] + let symlink_created = std::os::windows::fs::symlink_dir(&outside, &assets).is_ok(); + #[cfg(not(windows))] + let symlink_created = std::os::unix::fs::symlink(&outside, &assets).is_ok(); + if !symlink_created || !assets.join("secret.png").is_file() { + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + return; + } + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/secret.png".to_string(), + destination_path: destination_directory + .join("leak.png") + .to_string_lossy() + .into_owned(), + }) + .expect_err("source behind an intermediate symlink must be rejected"); + assert!(error.contains("符号链接"), "意外错误:{error}"); + assert!(!destination_directory.join("leak.png").exists()); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 保存目标就是源文件时必须拒绝,并且源素材一个字节都不能变。 + #[test] + fn rejects_destination_that_is_the_source_file() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let source = assets.join("hero.png"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: source.to_string_lossy().into_owned(), + }) + .expect_err("destination equal to the source must be rejected"); + + assert!(error.contains("不能与素材源文件相同"), "{error}"); + assert_eq!( + std::fs::read(&source).expect("read source asset"), + source_bytes, + "拒绝自我覆盖时源素材必须原样保留" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + /// 硬链接指向同一份文件时同样拒绝:路径不同但内容是同一份,覆盖等于毁掉源素材。 + #[test] + fn rejects_hard_linked_destination_pointing_at_the_source() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + let destination = destination_directory.join("hero-alias.png"); + if std::fs::hard_link(assets.join("hero.png"), &destination).is_err() { + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + return; + } + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination.to_string_lossy().into_owned(), + }) + .expect_err("hard-linked destination must be rejected"); + assert!(error.contains("不能与素材源文件相同"), "{error}"); + assert_eq!( + std::fs::read(assets.join("hero.png")).expect("read source asset"), + source_bytes + ); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + /// 覆盖既有目标走临时文件 + 原子替换:内容整体换新,且不留临时文件残渣。 + #[test] + fn replaces_an_existing_destination_without_leaving_temp_files() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + let destination = destination_directory.join("hero-copy.png"); + std::fs::write(&destination, b"stale-and-longer-content").expect("write stale destination"); + + let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination.to_string_lossy().into_owned(), + }) + .expect("save asset file"); + + assert_eq!(result.byte_len, source_bytes.len() as u64); + assert_eq!( + std::fs::read(&destination).expect("read copied asset"), + source_bytes + ); + let entries = std::fs::read_dir(&destination_directory) + .expect("read destination directory") + .count(); + assert_eq!(entries, 1, "原子替换不得留下临时文件"); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } }