update_kit/platform/
replace.rs1use crate::errors::UpdateKitError;
2use std::path::Path;
3
4pub async fn atomic_replace(source: &Path, target: &Path) -> Result<(), UpdateKitError> {
13 check_write_permission(target).await?;
15
16 #[cfg(unix)]
17 {
18 atomic_replace_unix(source, target).await
19 }
20
21 #[cfg(windows)]
22 {
23 atomic_replace_windows(source, target).await
24 }
25}
26
27async fn check_write_permission(target: &Path) -> Result<(), UpdateKitError> {
28 if target.exists() {
29 let metadata = tokio::fs::metadata(target).await.map_err(|e| {
30 UpdateKitError::PermissionDenied(format!(
31 "Cannot access target file {}: {e}",
32 target.display()
33 ))
34 })?;
35
36 if metadata.permissions().readonly() {
38 return Err(UpdateKitError::PermissionDenied(format!(
39 "Target file is read-only: {}",
40 target.display()
41 )));
42 }
43 }
44 Ok(())
45}
46
47#[cfg(unix)]
48async fn atomic_replace_unix(source: &Path, target: &Path) -> Result<(), UpdateKitError> {
49 use std::os::unix::fs::PermissionsExt;
50
51 let pid = std::process::id();
52 let temp_path = target.with_extension(format!("new.{pid}"));
53
54 tokio::fs::copy(source, &temp_path)
56 .await
57 .map_err(|e| {
58 UpdateKitError::ApplyFailed(format!(
59 "Failed to copy {} to {}: {e}",
60 source.display(),
61 temp_path.display()
62 ))
63 })?;
64
65 let perms = std::fs::Permissions::from_mode(0o755);
67 if let Err(e) = tokio::fs::set_permissions(&temp_path, perms).await {
68 let _ = tokio::fs::remove_file(&temp_path).await;
70 return Err(UpdateKitError::ApplyFailed(format!(
71 "Failed to set permissions on {}: {e}",
72 temp_path.display()
73 )));
74 }
75
76 if let Err(e) = tokio::fs::rename(&temp_path, target).await {
78 let _ = tokio::fs::remove_file(&temp_path).await;
80 return Err(UpdateKitError::ApplyFailed(format!(
81 "Failed to rename {} to {}: {e}",
82 temp_path.display(),
83 target.display()
84 )));
85 }
86
87 Ok(())
88}
89
90#[cfg(windows)]
91async fn atomic_replace_windows(source: &Path, target: &Path) -> Result<(), UpdateKitError> {
92 let old_path = target.with_extension("old");
93
94 let _ = tokio::fs::remove_file(&old_path).await;
96
97 if target.exists() {
99 tokio::fs::rename(target, &old_path).await.map_err(|e| {
100 UpdateKitError::ApplyFailed(format!(
101 "Failed to rename {} to {}: {e}",
102 target.display(),
103 old_path.display()
104 ))
105 })?;
106 }
107
108 if let Err(e) = tokio::fs::copy(source, target).await {
110 if old_path.exists() {
112 let _ = tokio::fs::rename(&old_path, target).await;
113 }
114 return Err(UpdateKitError::ApplyFailed(format!(
115 "Failed to copy {} to {}: {e}",
116 source.display(),
117 target.display()
118 )));
119 }
120
121 let _ = tokio::fs::remove_file(&old_path).await;
123
124 Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use tempfile::TempDir;
131
132 #[tokio::test]
133 async fn atomic_replace_success() {
134 let dir = TempDir::new().unwrap();
135
136 let target = dir.path().join("myapp");
137 let source = dir.path().join("myapp_new");
138
139 tokio::fs::write(&target, b"old content").await.unwrap();
141 tokio::fs::write(&source, b"new content").await.unwrap();
142
143 atomic_replace(&source, &target).await.unwrap();
145
146 let content = tokio::fs::read_to_string(&target).await.unwrap();
148 assert_eq!(content, "new content");
149 }
150
151 #[tokio::test]
152 async fn replace_creates_target_if_not_exists() {
153 let dir = TempDir::new().unwrap();
154 let source = dir.path().join("source_bin");
155 let target = dir.path().join("new_target");
156
157 tokio::fs::write(&source, b"new binary").await.unwrap();
158 let result = atomic_replace(&source, &target).await;
161 assert!(result.is_ok());
162 let content = tokio::fs::read_to_string(&target).await.unwrap();
163 assert_eq!(content, "new binary");
164 }
165
166 #[tokio::test]
167 async fn replace_preserves_content_exactly() {
168 let dir = TempDir::new().unwrap();
169 let source = dir.path().join("source");
170 let target = dir.path().join("target");
171
172 let data = vec![0u8, 1, 2, 255, 128, 64];
173 tokio::fs::write(&source, &data).await.unwrap();
174 tokio::fs::write(&target, b"old").await.unwrap();
175
176 atomic_replace(&source, &target).await.unwrap();
177 let result = tokio::fs::read(&target).await.unwrap();
178 assert_eq!(result, data);
179 }
180
181 #[cfg(unix)]
182 #[tokio::test]
183 async fn replace_sets_executable_permissions() {
184 use std::os::unix::fs::PermissionsExt;
185
186 let dir = TempDir::new().unwrap();
187 let source = dir.path().join("source");
188 let target = dir.path().join("target");
189
190 tokio::fs::write(&source, b"binary").await.unwrap();
191 tokio::fs::write(&target, b"old").await.unwrap();
192
193 atomic_replace(&source, &target).await.unwrap();
194 let metadata = tokio::fs::metadata(&target).await.unwrap();
195 let mode = metadata.permissions().mode();
196 assert_eq!(
197 mode & 0o777,
198 0o755,
199 "Expected 755 permissions, got: {mode:o}"
200 );
201 }
202
203 #[tokio::test]
204 async fn replace_source_not_found_fails() {
205 let dir = TempDir::new().unwrap();
206 let target = dir.path().join("target");
207 tokio::fs::write(&target, b"old").await.unwrap();
208
209 let result = atomic_replace(&dir.path().join("nonexistent"), &target).await;
210 assert!(result.is_err());
211 }
212
213 #[tokio::test]
214 async fn replace_idempotent() {
215 let dir = TempDir::new().unwrap();
216 let source = dir.path().join("source");
217 let target = dir.path().join("target");
218
219 tokio::fs::write(&source, b"content").await.unwrap();
220 tokio::fs::write(&target, b"old").await.unwrap();
221
222 atomic_replace(&source, &target).await.unwrap();
223 tokio::fs::write(&source, b"content2").await.unwrap();
225 atomic_replace(&source, &target).await.unwrap();
226 let content = tokio::fs::read_to_string(&target).await.unwrap();
227 assert_eq!(content, "content2");
228 }
229
230 #[tokio::test]
231 async fn check_write_permission_writable_file() {
232 let dir = TempDir::new().unwrap();
233 let target = dir.path().join("writable");
234 tokio::fs::write(&target, b"data").await.unwrap();
235
236 let result = check_write_permission(&target).await;
237 assert!(result.is_ok());
238 }
239
240 #[tokio::test]
241 async fn check_write_permission_nonexistent_file() {
242 let dir = TempDir::new().unwrap();
243 let target = dir.path().join("nonexistent");
244 let result = check_write_permission(&target).await;
246 assert!(result.is_ok());
247 }
248
249 #[cfg(unix)]
250 #[tokio::test]
251 async fn atomic_replace_no_write_permission() {
252 use std::os::unix::fs::PermissionsExt;
253
254 let dir = TempDir::new().unwrap();
255
256 let target = dir.path().join("readonly_app");
257 let source = dir.path().join("new_app");
258
259 tokio::fs::write(&target, b"old").await.unwrap();
260 tokio::fs::write(&source, b"new").await.unwrap();
261
262 let perms = std::fs::Permissions::from_mode(0o444);
264 tokio::fs::set_permissions(&target, perms).await.unwrap();
265
266 let result = atomic_replace(&source, &target).await;
268 assert!(result.is_err());
269 let err = result.unwrap_err();
270 assert!(
271 matches!(err, UpdateKitError::PermissionDenied(_)),
272 "Expected PermissionDenied, got: {err:?}"
273 );
274 }
275}