feat: добавить поддержку атомарной замены файлов для Windows и тесты на максимальную длину имени
This commit is contained in:
@@ -5,3 +5,6 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
|
||||||
|
|||||||
@@ -651,18 +651,43 @@ fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
|||||||
file.flush()?;
|
file.flush()?;
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
match fs::rename(&tmp_path, path) {
|
if let Err(err) = replace_file_atomically(&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);
|
let _ = fs::remove_file(&tmp_path);
|
||||||
Err(Error::Io(rename_err))
|
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<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||||
|
let dst_wide: Vec<u16> = 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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -770,6 +770,41 @@ fn nres_synthetic_read_find_and_edit() {
|
|||||||
let _ = fs::remove_file(&path);
|
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]
|
#[test]
|
||||||
fn nres_find_falls_back_when_sort_index_is_out_of_range() {
|
fn nres_find_falls_back_when_sort_index_is_out_of_range() {
|
||||||
let mut bytes = build_nres_bytes(&[
|
let mut bytes = build_nres_bytes(&[
|
||||||
|
|||||||
@@ -802,6 +802,102 @@ fn rsli_synthetic_all_methods_roundtrip() {
|
|||||||
let _ = fs::remove_file(&path);
|
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<u8> = (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]
|
#[test]
|
||||||
fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() {
|
fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() {
|
||||||
let entries = vec![
|
let entries = vec![
|
||||||
|
|||||||
Reference in New Issue
Block a user