Skip to main content

corundum_framework_update/
replace.rs

1//! Replacing another program's executable, which may be running.
2
3use std::ffi::OsString;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use crate::Error;
8use crate::Result;
9use crate::install::stage;
10
11/// The file operations a replacement makes, so the order they happen in can be tested without
12/// a Windows machine.
13pub(crate) trait Fs {
14    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
15    fn remove(&self, path: &Path) -> io::Result<()>;
16    fn exists(&self, path: &Path) -> bool;
17}
18
19pub(crate) struct RealFs;
20
21impl Fs for RealFs {
22    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
23        std::fs::rename(from, to)
24    }
25    fn remove(&self, path: &Path) -> io::Result<()> {
26        std::fs::remove_file(path)
27    }
28    fn exists(&self, path: &Path) -> bool {
29        path.exists()
30    }
31}
32
33/// Replace the executable at `path` with `bytes`, even while it runs: a running program keeps
34/// the old file (unix) or can be renamed but not overwritten (Windows), so the new one is
35/// written beside it and moved into its place. Running copies go on with the old code until
36/// they are restarted.
37///
38/// The staging file is removed on every error.
39pub fn replace_file(path: &Path, bytes: &[u8]) -> Result<()> {
40    let dir = path
41        .parent()
42        .filter(|dir| !dir.as_os_str().is_empty())
43        .unwrap_or(Path::new("."));
44    // Close the file: Windows will not rename one that is open. The path still removes it
45    // when dropped.
46    let staged = stage(dir, bytes)?.into_temp_path();
47    if cfg!(windows) {
48        replace_windows(&RealFs, path, &staged)
49    } else {
50        replace_unix(&RealFs, path, &staged)
51    }
52    .map_err(Error::Io)
53}
54
55/// A rename over the target replaces it atomically; processes running it keep the old inode.
56pub(crate) fn replace_unix(fs: &dyn Fs, path: &Path, staged: &Path) -> io::Result<()> {
57    fs.rename(staged, path)
58}
59
60/// `path` with `.old`, and `.<n>` after it when `n` is not 0, appended.
61fn old_name(path: &Path, n: u32) -> PathBuf {
62    let mut name = OsString::from(path.as_os_str());
63    name.push(".old");
64    if n > 0 {
65        name.push(format!(".{n}"));
66    }
67    PathBuf::from(name)
68}
69
70/// Windows cannot overwrite or delete a running executable but can rename it: set the target
71/// aside as `path.old`, then move the new file in, putting the old one back if that fails.
72///
73/// A set-aside file stays locked until its process exits, so the next update removes
74/// `path.old` and `path.old.<n>` first; one that cannot be removed is skipped, and the
75/// target goes to the first name that is free.
76pub(crate) fn replace_windows(fs: &dyn Fs, path: &Path, staged: &Path) -> io::Result<()> {
77    // Stops at the first absent `.old*` name; a later leftover is reached once the next update
78    // fills the gap.
79    let mut n = 0;
80    while fs.exists(&old_name(path, n)) {
81        // Still locked by a running copy: leave it and use another name.
82        let _ = fs.remove(&old_name(path, n));
83        n += 1;
84    }
85    let old = (0..)
86        .map(|n| old_name(path, n))
87        .find(|old| !fs.exists(old))
88        .expect("an unbounded range has a free name");
89    fs.rename(path, &old)?;
90    if let Err(e) = fs.rename(staged, path) {
91        let _ = fs.rename(&old, path);
92        return Err(e);
93    }
94    Ok(())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::cell::RefCell;
101    use std::collections::HashSet;
102
103    /// Records the calls that change anything, and fails the one named by `fail`.
104    #[derive(Default)]
105    struct RecordingFs {
106        calls: RefCell<Vec<String>>,
107        present: RefCell<HashSet<PathBuf>>,
108        fail: Option<String>,
109    }
110
111    impl RecordingFs {
112        fn with(present: &[&str]) -> Self {
113            Self {
114                present: RefCell::new(present.iter().map(PathBuf::from).collect()),
115                ..Self::default()
116            }
117        }
118        fn failing(mut self, call: &str) -> Self {
119            self.fail = Some(call.to_owned());
120            self
121        }
122        fn record(&self, call: String) -> io::Result<()> {
123            self.calls.borrow_mut().push(call.clone());
124            if self.fail.as_deref() == Some(call.as_str()) {
125                return Err(io::Error::other("injected"));
126            }
127            Ok(())
128        }
129        fn calls(&self) -> Vec<String> {
130            self.calls.borrow().clone()
131        }
132    }
133
134    impl Fs for RecordingFs {
135        fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
136            self.record(format!("rename {} {}", from.display(), to.display()))?;
137            let mut present = self.present.borrow_mut();
138            present.remove(from);
139            present.insert(to.to_owned());
140            Ok(())
141        }
142        fn remove(&self, path: &Path) -> io::Result<()> {
143            self.record(format!("remove {}", path.display()))?;
144            self.present.borrow_mut().remove(path);
145            Ok(())
146        }
147        fn exists(&self, path: &Path) -> bool {
148            self.present.borrow().contains(path)
149        }
150    }
151
152    #[test]
153    fn unix_renames_over_the_target() {
154        let fs = RecordingFs::default();
155        replace_unix(&fs, Path::new("/d/app"), Path::new("/d/.update-x")).unwrap();
156        assert_eq!(fs.calls(), ["rename /d/.update-x /d/app"]);
157    }
158
159    #[test]
160    fn windows_sets_the_running_exe_aside() {
161        let fs = RecordingFs::with(&["/d/app", "/d/.update-x"]);
162        replace_windows(&fs, Path::new("/d/app"), Path::new("/d/.update-x")).unwrap();
163        assert_eq!(
164            fs.calls(),
165            ["rename /d/app /d/app.old", "rename /d/.update-x /d/app"]
166        );
167    }
168
169    #[test]
170    fn windows_removes_an_earlier_leftover_first() {
171        let fs = RecordingFs::with(&["/d/app", "/d/app.old", "/d/app.old.1"]);
172        replace_windows(&fs, Path::new("/d/app"), Path::new("/d/.update-x")).unwrap();
173        assert_eq!(
174            fs.calls(),
175            [
176                "remove /d/app.old",
177                "remove /d/app.old.1",
178                "rename /d/app /d/app.old",
179                "rename /d/.update-x /d/app"
180            ]
181        );
182    }
183
184    #[test]
185    fn windows_goes_on_when_the_leftover_is_locked() {
186        let fs = RecordingFs::with(&["/d/app", "/d/app.old"]).failing("remove /d/app.old");
187        replace_windows(&fs, Path::new("/d/app"), Path::new("/d/.update-x")).unwrap();
188        assert_eq!(
189            fs.calls(),
190            [
191                "remove /d/app.old",
192                "rename /d/app /d/app.old.1",
193                "rename /d/.update-x /d/app"
194            ]
195        );
196    }
197
198    #[test]
199    fn windows_puts_the_old_one_back_when_the_move_fails() {
200        let fs = RecordingFs::with(&["/d/app"]).failing("rename /d/.update-x /d/app");
201        let result = replace_windows(&fs, Path::new("/d/app"), Path::new("/d/.update-x"));
202        assert!(result.is_err());
203        assert_eq!(fs.calls().last().unwrap(), "rename /d/app.old /d/app");
204    }
205
206    #[test]
207    fn the_real_thing_replaces_content_and_keeps_it_executable() {
208        let tmp = tempfile::tempdir().unwrap();
209        let f = tmp.path().join("f");
210        std::fs::write(&f, b"old").unwrap();
211        #[cfg(unix)]
212        {
213            use std::os::unix::fs::PermissionsExt;
214            std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
215        }
216        replace_file(&f, b"new").unwrap();
217        assert_eq!(std::fs::read(&f).unwrap(), b"new");
218        #[cfg(unix)]
219        {
220            use std::os::unix::fs::PermissionsExt;
221            assert_eq!(
222                std::fs::metadata(&f).unwrap().permissions().mode() & 0o777,
223                0o755
224            );
225        }
226        // Windows keeps the old file beside it until the next update.
227        let left = std::fs::read_dir(tmp.path()).unwrap().count();
228        assert_eq!(left, if cfg!(windows) { 2 } else { 1 });
229    }
230
231    #[test]
232    #[cfg(unix)]
233    fn a_failed_replace_leaves_no_staging_file() {
234        let tmp = tempfile::tempdir().unwrap();
235        // Renaming a file over a directory fails, after the file is staged.
236        let dir = tmp.path().join("d");
237        std::fs::create_dir(&dir).unwrap();
238        assert!(replace_file(&dir, b"new").is_err());
239        let left: Vec<_> = std::fs::read_dir(tmp.path()).unwrap().collect();
240        assert_eq!(left.len(), 1);
241        assert_eq!(left[0].as_ref().unwrap().path(), dir);
242    }
243}