99use std:: io;
1010use std:: path:: Path ;
1111
12- // ---------------------------------------------------------------------------
13- // Private sync core
14- // ---------------------------------------------------------------------------
15-
1612fn 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.
156217pub 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) ]
197254mod 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