From 3c06e768d603ea4452fc95cc48951af9270f76a1 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Wed, 11 Feb 2026 22:00:46 +0000 Subject: feat: добавить поддержку атомарной замены файлов для Windows и тесты на максимальную длину имени MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/nres/Cargo.toml | 3 ++ crates/nres/src/lib.rs | 49 ++++++++++++++++++------ crates/nres/src/tests.rs | 35 ++++++++++++++++++ crates/rsli/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml index 7f85352..25f3494 100644 --- a/crates/nres/Cargo.toml +++ b/crates/nres/Cargo.toml @@ -5,3 +5,6 @@ edition = "2021" [dependencies] common = { path = "../common" } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs index 1fa3b39..e0631e3 100644 --- a/crates/nres/src/lib.rs +++ b/crates/nres/src/lib.rs @@ -651,18 +651,43 @@ fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { file.flush()?; drop(file); - match fs::rename(&tmp_path, path) { - Ok(()) => Ok(()), - Err(rename_err) => { - if path.exists() { - fs::remove_file(path)?; - fs::rename(&tmp_path, path)?; - Ok(()) - } else { - let _ = fs::remove_file(&tmp_path); - Err(Error::Io(rename_err)) - } - } + if let Err(err) = replace_file_atomically(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(Error::Io(err)); + } + + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::rename(src, dst) +} + +#[cfg(windows)] +fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> { + use std::iter; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let src_wide: Vec = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); + let dst_wide: Vec = dst.as_os_str().encode_wide().chain(iter::once(0)).collect(); + + // Replace destination in one OS call, avoiding remove+rename gaps on Windows. + let ok = unsafe { + MoveFileExW( + src_wide.as_ptr(), + dst_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + + if ok == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) } } diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs index 51ec8b4..6de02e5 100644 --- a/crates/nres/src/tests.rs +++ b/crates/nres/src/tests.rs @@ -770,6 +770,41 @@ fn nres_synthetic_read_find_and_edit() { let _ = fs::remove_file(&path); } +#[test] +fn nres_max_name_length_roundtrip() { + let max_name = "12345678901234567890123456789012345"; + assert_eq!(max_name.len(), 35); + + let src = build_nres_bytes(&[SyntheticEntry { + kind: 9, + attr1: 1, + attr2: 2, + attr3: 3, + name: max_name, + data: b"payload", + }]); + + let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default()) + .expect("open synthetic nres failed"); + + assert_eq!(archive.entry_count(), 1); + assert_eq!(archive.find(max_name), Some(EntryId(0))); + assert_eq!( + archive.find(&max_name.to_ascii_lowercase()), + Some(EntryId(0)) + ); + + let entry = archive.get(EntryId(0)).expect("missing entry 0"); + assert_eq!(entry.meta.name, max_name); + assert_eq!( + archive + .read(EntryId(0)) + .expect("read payload failed") + .as_slice(), + b"payload" + ); +} + #[test] fn nres_find_falls_back_when_sort_index_is_out_of_range() { let mut bytes = build_nres_bytes(&[ diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs index 33fc71b..07807d3 100644 --- a/crates/rsli/src/tests.rs +++ b/crates/rsli/src/tests.rs @@ -802,6 +802,102 @@ fn rsli_synthetic_all_methods_roundtrip() { let _ = fs::remove_file(&path); } +#[test] +fn rsli_empty_archive_roundtrip() { + let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default()); + let path = write_temp_file("rsli-empty", &bytes); + + let library = Library::open_path(&path).expect("open empty rsli failed"); + assert_eq!(library.entry_count(), 0); + assert_eq!(library.find("ANYTHING"), None); + + let rebuilt = library + .rebuild_from_parsed_metadata() + .expect("rebuild empty rsli failed"); + assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch"); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_max_name_length_without_nul_roundtrip() { + let max_name = "NAME12345678"; + assert_eq!(max_name.len(), 12); + + let bytes = build_rsli_bytes( + &[SyntheticRsliEntry { + name: max_name.to_string(), + method_raw: 0x000, + plain: b"payload".to_vec(), + declared_packed_size: None, + }], + &RsliBuildOptions::default(), + ); + let path = write_temp_file("rsli-max-name", &bytes); + + let library = Library::open_path(&path).expect("open max-name rsli failed"); + assert_eq!(library.entry_count(), 1); + assert_eq!(library.find(max_name), Some(EntryId(0))); + assert_eq!( + library.find(&max_name.to_ascii_lowercase()), + Some(EntryId(0)) + ); + assert_eq!( + library.entries[0] + .name_raw + .iter() + .position(|byte| *byte == 0), + None, + "name_raw must occupy full 12 bytes without NUL" + ); + + let entry = library.get(EntryId(0)).expect("missing entry"); + assert_eq!(entry.meta.name, max_name); + assert_eq!( + library.load(EntryId(0)).expect("load failed"), + b"payload", + "payload mismatch" + ); + + let _ = fs::remove_file(&path); +} + +#[test] +fn rsli_lzss_large_payload_over_4k_roundtrip() { + let plain: Vec = (0..10_000u32).map(|v| (v % 251) as u8).collect(); + let entries = vec![ + SyntheticRsliEntry { + name: "LZSS4K".to_string(), + method_raw: 0x040, + plain: plain.clone(), + declared_packed_size: None, + }, + SyntheticRsliEntry { + name: "XLZS4K".to_string(), + method_raw: 0x060, + plain: plain.clone(), + declared_packed_size: None, + }, + ]; + let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default()); + let path = write_temp_file("rsli-lzss-4k", &bytes); + + let library = Library::open_path(&path).expect("open large-lzss rsli failed"); + assert_eq!(library.entry_count(), entries.len()); + + for entry in &entries { + let id = library + .find(&entry.name) + .unwrap_or_else(|| panic!("find failed for {}", entry.name)); + let loaded = library + .load(id) + .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name)); + assert_eq!(loaded, plain, "payload mismatch for {}", entry.name); + } + + let _ = fs::remove_file(&path); +} + #[test] fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() { let entries = vec![ -- cgit v1.2.3