Skip to content

Commit 775d1d5

Browse files
committed
feat(hm-util): add atomic_rename_over with ReplaceFileW on Windows
1 parent 20f30f4 commit 775d1d5

1 file changed

Lines changed: 104 additions & 20 deletions

File tree

  • crates/hm-util/src/os

crates/hm-util/src/os/fs.rs

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@
99
use std::io;
1010
use std::path::Path;
1111

12-
// ---------------------------------------------------------------------------
13-
// Private sync core
14-
// ---------------------------------------------------------------------------
15-
1612
fn write_atomic_restricted_sync(
1713
path: &Path,
1814
contents: &[u8],
@@ -101,9 +97,55 @@ fn write_file_with_mode_sync(path: &Path, contents: &[u8], _mode: u32) -> io::Re
10197
}
10298

10399
// ---------------------------------------------------------------------------
104-
// Public async API
100+
// Cross-platform atomic rename
105101
// ---------------------------------------------------------------------------
106102

103+
#[cfg(unix)]
104+
fn atomic_rename_over_sync(from: &Path, to: &Path) -> io::Result<()> {
105+
std::fs::rename(from, to)
106+
}
107+
108+
#[cfg(windows)]
109+
fn atomic_rename_over_sync(from: &Path, to: &Path) -> io::Result<()> {
110+
use windows::core::HSTRING;
111+
use windows::Win32::Storage::FileSystem::{
112+
MoveFileExW, ReplaceFileW,
113+
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
114+
REPLACEFILE_IGNORE_MERGE_ERRORS,
115+
};
116+
117+
let from_w = HSTRING::from(from.as_os_str());
118+
let to_w = HSTRING::from(to.as_os_str());
119+
120+
// ReplaceFileW preserves ACLs and alternate data streams on the
121+
// target, but requires the target to already exist.
122+
if to.exists() {
123+
let result = unsafe {
124+
ReplaceFileW(
125+
&to_w,
126+
&from_w,
127+
windows::core::PCWSTR::null(),
128+
REPLACEFILE_IGNORE_MERGE_ERRORS,
129+
None,
130+
None,
131+
)
132+
};
133+
return result.map_err(|e| io::Error::new(io::ErrorKind::Other, e));
134+
}
135+
136+
// Target doesn't exist yet — fall back to MoveFileExW which handles
137+
// both cases but doesn't preserve target metadata (irrelevant here
138+
// since there is no target).
139+
let result = unsafe {
140+
MoveFileExW(
141+
&from_w,
142+
&to_w,
143+
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
144+
)
145+
};
146+
result.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
147+
}
148+
107149
/// Write `contents` to `path` atomically with `file_mode`, ensuring the
108150
/// parent directory exists and is set to `dir_mode`.
109151
///
@@ -131,6 +173,28 @@ pub async fn write_atomic_restricted(
131173
.map_err(io::Error::other)?
132174
}
133175

176+
/// Atomically replace `to` with `from`.
177+
///
178+
/// On Unix this is a single `rename(2)` call — atomic by POSIX
179+
/// guarantee. On Windows this uses `ReplaceFileW` (preserves ACLs
180+
/// and alternate data streams) when the target exists, falling back
181+
/// to `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` for first-write.
182+
///
183+
/// # Errors
184+
///
185+
/// Returns an error if the rename fails (permission denied, cross-device,
186+
/// source missing, etc.).
187+
pub async fn atomic_rename_over(
188+
from: impl AsRef<Path>,
189+
to: impl AsRef<Path>,
190+
) -> io::Result<()> {
191+
let from = from.as_ref().to_owned();
192+
let to = to.as_ref().to_owned();
193+
tokio::task::spawn_blocking(move || atomic_rename_over_sync(&from, &to))
194+
.await
195+
.map_err(io::Error::other)?
196+
}
197+
134198
/// Remove a file if it exists; silently return `Ok(())` if it does not.
135199
///
136200
/// This is the async counterpart of [`blocking::remove_if_exists`];
@@ -140,17 +204,14 @@ pub async fn write_atomic_restricted(
140204
///
141205
/// Returns an error if `remove_file` fails for any reason other than
142206
/// `NotFound`.
143-
pub async fn remove_if_exists(path: impl AsRef<Path>) -> io::Result<()> {
144-
let path = path.as_ref().to_owned();
145-
tokio::task::spawn_blocking(move || remove_if_exists_sync(&path))
146-
.await
147-
.map_err(io::Error::other)?
207+
pub async fn remove_file_if_exists(path: impl AsRef<Path>) -> io::Result<()> {
208+
match tokio::fs::remove_file(path).await {
209+
Ok(()) => Ok(()),
210+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
211+
Err(e) => Err(e),
212+
}
148213
}
149214

150-
// ---------------------------------------------------------------------------
151-
// Public blocking module
152-
// ---------------------------------------------------------------------------
153-
154215
/// Synchronous (blocking) wrappers for callers that cannot use async,
155216
/// such as extism `host_fn` callbacks.
156217
pub mod blocking {
@@ -188,10 +249,6 @@ pub mod blocking {
188249
}
189250
}
190251

191-
// ---------------------------------------------------------------------------
192-
// Tests
193-
// ---------------------------------------------------------------------------
194-
195252
#[cfg(all(test, unix))]
196253
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
197254
mod tests {
@@ -271,9 +328,36 @@ mod tests {
271328
async fn async_remove_if_exists() {
272329
let tmp = tempfile::tempdir().unwrap();
273330
let target = tmp.path().join("nothing");
274-
super::remove_if_exists(&target).await.unwrap();
331+
super::remove_file_if_exists(&target).await.unwrap();
275332
std::fs::write(&target, "x").unwrap();
276-
super::remove_if_exists(&target).await.unwrap();
333+
super::remove_file_if_exists(&target).await.unwrap();
277334
assert!(!target.exists());
278335
}
336+
337+
#[tokio::test]
338+
async fn atomic_rename_over_replaces_target() {
339+
let tmp = tempfile::tempdir().unwrap();
340+
let src = tmp.path().join("source");
341+
let dst = tmp.path().join("target");
342+
std::fs::write(&dst, b"old").unwrap();
343+
std::fs::write(&src, b"new").unwrap();
344+
345+
super::atomic_rename_over(&src, &dst).await.unwrap();
346+
347+
assert_eq!(std::fs::read(&dst).unwrap(), b"new");
348+
assert!(!src.exists(), "source should be gone after rename");
349+
}
350+
351+
#[tokio::test]
352+
async fn atomic_rename_over_works_when_target_missing() {
353+
let tmp = tempfile::tempdir().unwrap();
354+
let src = tmp.path().join("source");
355+
let dst = tmp.path().join("target");
356+
std::fs::write(&src, b"new").unwrap();
357+
358+
super::atomic_rename_over(&src, &dst).await.unwrap();
359+
360+
assert_eq!(std::fs::read(&dst).unwrap(), b"new");
361+
assert!(!src.exists());
362+
}
279363
}

0 commit comments

Comments
 (0)