Skip to main content

update_kit/platform/
replace.rs

1use crate::errors::UpdateKitError;
2use std::path::Path;
3
4/// Atomically replace the file at `target` with the contents of `source`.
5///
6/// On Unix: copies source to a temp file next to target, sets executable
7/// permissions (0o755), then renames over the target (atomic on the same
8/// filesystem). Cleans up the temp file on failure.
9///
10/// On Windows: renames target to `.old`, copies source to target, then
11/// removes the `.old` file. Rolls back on copy failure.
12pub async fn atomic_replace(source: &Path, target: &Path) -> Result<(), UpdateKitError> {
13    // Check write permission on the target's parent directory
14    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        // Check if the file is read-only
37        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    // Copy source to temp file
55    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    // Set executable permissions
66    let perms = std::fs::Permissions::from_mode(0o755);
67    if let Err(e) = tokio::fs::set_permissions(&temp_path, perms).await {
68        // Cleanup temp file on failure
69        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    // Atomic rename
77    if let Err(e) = tokio::fs::rename(&temp_path, target).await {
78        // Cleanup temp file on failure
79        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    // Remove any leftover .old file
95    let _ = tokio::fs::remove_file(&old_path).await;
96
97    // Rename current target to .old (if target exists)
98    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    // Copy source to target
109    if let Err(e) = tokio::fs::copy(source, target).await {
110        // Rollback: restore old file
111        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    // Cleanup .old file
122    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        // Write initial content
140        tokio::fs::write(&target, b"old content").await.unwrap();
141        tokio::fs::write(&source, b"new content").await.unwrap();
142
143        // Replace
144        atomic_replace(&source, &target).await.unwrap();
145
146        // Verify content changed
147        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        // target doesn't exist yet
159
160        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        // Create source again (it may have been consumed)
224        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        // Non-existent target should be OK (we're creating a new file)
245        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        // Make target read-only
263        let perms = std::fs::Permissions::from_mode(0o444);
264        tokio::fs::set_permissions(&target, perms).await.unwrap();
265
266        // Should fail with permission denied
267        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}