diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs index 9275b5523..c27336066 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs @@ -1,7 +1,9 @@ use super::model::*; use crate::ui_editor::commands::separation::*; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; +use uuid::Uuid; const SEPARATION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024; pub fn separation_sidecar_dir(root: &Path, asset_id: &str) -> Result { @@ -53,8 +55,8 @@ pub async fn write_separation_state(path: PathBuf, state: &SeparationState) -> R .map_err(|error| format!("序列化 separation state 失败:{error}"))?; write_separation_state_blocking(&path, &bytes) }) - .await - .map_err(|error| format!("写入 separation state 任务失败:{error}"))? + .await + .map_err(|error| format!("写入 separation state 任务失败:{error}"))? } fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), String> { @@ -84,15 +86,40 @@ fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), Stri app_log!("ui_separation.error stage=state_write reason=create_parent error={error}"); format!("创建 separation sidecar 失败:{error}") })?; - let temporary = path.with_extension("json.tmp"); - fs::write(&temporary, bytes).map_err(|error| { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + app_log!("ui_separation.error stage=state_write reason=unsafe_target"); + return Err("separation state 目标必须是普通文件".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + app_log!("ui_separation.error stage=state_write reason=target_metadata error={error}"); + return Err(format!("检查 separation state 目标失败:{error}")); + } + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state.json"); + let temporary = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4())); + let mut temporary_file = OpenOptions::new(); + temporary_file.write(true).create_new(true); + let mut file = temporary_file.open(&temporary).map_err(|error| { app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); format!("写入 separation state 失败:{error}") })?; - fs::rename(&temporary, path).map_err(|error| { + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_data()) { + let _ = fs::remove_file(&temporary); + app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); + return Err(format!("写入 separation state 失败:{error}")); + } + drop(file); + if let Err(error) = replace_separation_state_atomically(&temporary, path) { + let _ = fs::remove_file(&temporary); app_log!("ui_separation.error stage=state_write reason=install error={error}"); - format!("安装 separation state 失败:{error}") - })?; + return Err(format!("安装 separation state 失败:{error}")); + } app_log!( "ui_separation.state_write.completed file={} bytes={}", path.file_name() @@ -105,6 +132,42 @@ fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), Stri Ok(()) } +#[cfg(not(windows))] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + fs::rename(temporary, target) +} + +#[cfg(windows)] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let source = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + pub fn read_separation_state(path: &Path) -> Result { app_log!( "ui_separation.state_read.start file={}", @@ -215,3 +278,23 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> { Err(error) => Err(format!("删除 separation state 失败:{error}")), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atomic_state_replacement_overwrites_existing_target() { + let directory = tempfile::tempdir().expect("create temporary state directory"); + let target = directory.path().join("state.json"); + let temporary = directory.path().join("state.json.tmp"); + fs::write(&target, b"old").expect("write old state"); + fs::write(&temporary, b"new").expect("write new state"); + + replace_separation_state_atomically(&temporary, &target) + .expect("replacement should overwrite existing state"); + + assert_eq!(fs::read(&target).expect("read replaced state"), b"new"); + assert!(!temporary.exists()); + } +}