Skip to main content

renew/
engine.rs

1use std::ffi::OsString;
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use crate::manifest::Binary;
8use crate::{DOWNLOAD_TIMEOUT, RenewError};
9
10const CHUNK: usize = 64 * 1024;
11
12pub fn staging_path(exe: &Path) -> PathBuf {
13    beside(exe, ".staging")
14}
15
16fn old_path(exe: &Path) -> PathBuf {
17    beside(exe, ".old")
18}
19
20pub(crate) fn beside(exe: &Path, suffix: &str) -> PathBuf {
21    let mut name = OsString::from(exe.as_os_str());
22    name.push(suffix);
23    PathBuf::from(name)
24}
25
26pub fn install(binary: &Binary, exe: &Path) -> Result<(), RenewError> {
27    #[cfg(target_os = "macos")]
28    if let Some(bundle) = crate::bundle::bundle_of(exe) {
29        return crate::bundle::install(binary, &bundle);
30    }
31    let response = crate::get(&binary.url, DOWNLOAD_TIMEOUT, RenewError::DownloadFailed)?;
32    let staging = staging_path(exe);
33    let file = create_staging(&staging)?;
34    let outcome = download_verified(
35        &binary.sha256,
36        binary.size,
37        response.into_body().into_reader(),
38        file,
39    )
40    .and_then(|()| swap_in(exe, &staging));
41    if outcome.is_err() {
42        let _ = std::fs::remove_file(&staging);
43    }
44    outcome
45}
46
47#[cfg(unix)]
48fn create_staging(staging: &Path) -> Result<File, RenewError> {
49    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
50    let file = std::fs::OpenOptions::new()
51        .write(true)
52        .create_new(true)
53        .mode(0o700)
54        .open(staging)
55        .map_err(|source| RenewError::StageFailed { source })?;
56    if let Err(source) = file.set_permissions(std::fs::Permissions::from_mode(0o755)) {
57        drop(file);
58        let _ = std::fs::remove_file(staging);
59        return Err(RenewError::StageFailed { source });
60    }
61    Ok(file)
62}
63
64#[cfg(windows)]
65fn create_staging(staging: &Path) -> Result<File, RenewError> {
66    std::fs::OpenOptions::new()
67        .write(true)
68        .create_new(true)
69        .open(staging)
70        .map_err(|source| RenewError::StageFailed { source })
71}
72
73pub(crate) fn download_verified(
74    sha256: &str,
75    size: u64,
76    mut reader: impl Read,
77    mut file: File,
78) -> Result<(), RenewError> {
79    let mut hasher = fetch::StreamHasher::new();
80    let mut received: u64 = 0;
81    let mut buf = [0u8; CHUNK];
82    loop {
83        let got = reader
84            .read(&mut buf)
85            .map_err(|_| RenewError::DownloadFailed)?;
86        if got == 0 {
87            break;
88        }
89        received += got as u64;
90        if received > size {
91            return Err(RenewError::VerifyFailed {
92                detail: format!("more than the published {size} bytes arrived"),
93            });
94        }
95        hasher.update(&buf[..got]);
96        file.write_all(&buf[..got])
97            .map_err(|source| RenewError::StageFailed { source })?;
98    }
99    file.sync_all()
100        .map_err(|source| RenewError::StageFailed { source })?;
101    drop(file);
102
103    if received != size {
104        return Err(RenewError::VerifyFailed {
105            detail: format!("{received} bytes arrived, {size} were published"),
106        });
107    }
108    let got_hex = hasher.finalize_hex();
109    if !got_hex.eq_ignore_ascii_case(sha256.trim()) {
110        return Err(RenewError::VerifyFailed {
111            detail: "the checksum does not match the published one".to_string(),
112        });
113    }
114    Ok(())
115}
116
117#[cfg(unix)]
118fn swap_in(exe: &Path, staging: &Path) -> Result<(), RenewError> {
119    std::fs::rename(staging, exe).map_err(|source| RenewError::SwapFailed { source })?;
120    if let Some(parent) = exe.parent()
121        && let Ok(dir) = File::open(parent)
122    {
123        let _ = dir.sync_all();
124    }
125    Ok(())
126}
127
128#[cfg(windows)]
129fn swap_in(exe: &Path, staging: &Path) -> Result<(), RenewError> {
130    let swap = || -> std::io::Result<()> {
131        let old = old_path(exe);
132        let _ = std::fs::remove_file(&old);
133        std::fs::rename(exe, &old)?;
134        if let Err(error) = std::fs::rename(staging, exe) {
135            let _ = std::fs::rename(&old, exe);
136            return Err(error);
137        }
138        Ok(())
139    };
140    swap().map_err(|source| RenewError::SwapFailed { source })
141}
142
143pub fn clean_old_binary(exe: &Path) {
144    #[cfg(target_os = "macos")]
145    if let Some(bundle) = crate::bundle::bundle_of(exe) {
146        crate::bundle::clean(&bundle);
147    }
148    let _ = std::fs::remove_file(old_path(exe));
149    let _ = std::fs::remove_file(staging_path(exe));
150}
151
152pub fn restart(exe: &Path) -> std::io::Result<()> {
153    #[cfg(target_os = "macos")]
154    if let Some(bundle) = crate::bundle::bundle_of(exe) {
155        return crate::bundle::relaunch(&bundle);
156    }
157    Command::new(exe)
158        .stdin(Stdio::null())
159        .stdout(Stdio::null())
160        .stderr(Stdio::null())
161        .spawn()
162        .map(|_| ())
163}
164
165#[cfg(test)]
166pub(crate) mod tests {
167    use super::*;
168
169    pub(crate) fn scratch() -> tempfile::TempDir {
170        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tmp/testscratch");
171        std::fs::create_dir_all(&root).unwrap();
172        tempfile::Builder::new()
173            .prefix("renew-")
174            .tempdir_in(root)
175            .unwrap()
176    }
177
178    pub(crate) fn serve_once(body: Vec<u8>) -> String {
179        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
180        let addr = listener.local_addr().unwrap();
181        std::thread::spawn(move || {
182            if let Ok((mut socket, _)) = listener.accept() {
183                let mut sink = [0u8; 4096];
184                let _ = socket.read(&mut sink);
185                let head = format!(
186                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
187                    body.len()
188                );
189                let _ = socket.write_all(head.as_bytes());
190                let _ = socket.write_all(&body);
191            }
192        });
193        format!("http://{addr}/work")
194    }
195
196    fn binary_for(body: &[u8], url: String) -> Binary {
197        Binary {
198            url,
199            sha256: fetch::sha256_hex(body),
200            size: body.len() as u64,
201            app: None,
202        }
203    }
204
205    #[test]
206    fn install_downloads_verifies_and_swaps_the_binary_in() {
207        let dir = scratch();
208        let exe = dir.path().join("pito-work");
209        std::fs::write(&exe, b"old binary").unwrap();
210        let body = b"the new binary bytes".to_vec();
211        let url = serve_once(body.clone());
212        let binary = binary_for(&body, url);
213
214        install(&binary, &exe).unwrap();
215
216        assert_eq!(std::fs::read(&exe).unwrap(), body);
217        assert!(!staging_path(&exe).exists(), "no staging file left behind");
218        #[cfg(unix)]
219        {
220            use std::os::unix::fs::PermissionsExt;
221            let mode = std::fs::metadata(&exe).unwrap().permissions().mode();
222            assert_eq!(
223                mode & 0o777,
224                0o755,
225                "the new binary is 0755 whatever the umask"
226            );
227        }
228    }
229
230    #[test]
231    fn a_checksum_mismatch_refuses_and_leaves_the_old_binary_untouched() {
232        let dir = scratch();
233        let exe = dir.path().join("pito-work");
234        std::fs::write(&exe, b"old binary").unwrap();
235        let body = b"tampered bytes".to_vec();
236        let url = serve_once(body.clone());
237        let mut binary = binary_for(&body, url);
238        binary.sha256 = "0".repeat(64);
239
240        let outcome = install(&binary, &exe);
241
242        assert!(matches!(outcome, Err(RenewError::VerifyFailed { .. })));
243        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
244        assert!(!staging_path(&exe).exists());
245    }
246
247    #[test]
248    fn a_size_mismatch_refuses_before_the_checksum_even_matters() {
249        let dir = scratch();
250        let exe = dir.path().join("pito-work");
251        std::fs::write(&exe, b"old binary").unwrap();
252        let body = b"short".to_vec();
253        let url = serve_once(body.clone());
254        let mut binary = binary_for(&body, url);
255        binary.size += 5;
256
257        let outcome = install(&binary, &exe);
258
259        assert!(matches!(outcome, Err(RenewError::VerifyFailed { .. })));
260        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
261    }
262
263    #[test]
264    fn an_unreachable_url_is_a_download_failure_not_a_panic() {
265        let dir = scratch();
266        let exe = dir.path().join("pito-work");
267        std::fs::write(&exe, b"old binary").unwrap();
268        let url = String::from("https://renew.invalid/gone");
269        let binary = Binary {
270            url,
271            sha256: "ab".repeat(32),
272            size: 4,
273            app: None,
274        };
275
276        assert!(matches!(
277            install(&binary, &exe),
278            Err(RenewError::DownloadFailed)
279        ));
280        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
281    }
282
283    #[test]
284    fn a_plain_http_binary_url_off_this_machine_is_refused_before_anything_is_staged() {
285        let dir = scratch();
286        let exe = dir.path().join("pito-work");
287        std::fs::write(&exe, b"old binary").unwrap();
288        let binary = Binary {
289            url: String::from("http://example.com/app"),
290            sha256: "ab".repeat(32),
291            size: 4,
292            app: None,
293        };
294
295        assert!(matches!(
296            install(&binary, &exe),
297            Err(RenewError::InsecureUrl)
298        ));
299        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
300        assert!(!staging_path(&exe).exists());
301    }
302
303    #[test]
304    fn an_install_already_in_flight_is_refused_and_its_staging_file_is_left_alone() {
305        let dir = scratch();
306        let exe = dir.path().join("pito-work");
307        std::fs::write(&exe, b"old binary").unwrap();
308        std::fs::write(staging_path(&exe), b"in flight").unwrap();
309        let body = b"the new binary bytes".to_vec();
310        let url = serve_once(body.clone());
311        let binary = binary_for(&body, url);
312
313        let outcome = install(&binary, &exe);
314
315        assert!(matches!(outcome, Err(RenewError::StageFailed { .. })));
316        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
317        assert_eq!(std::fs::read(staging_path(&exe)).unwrap(), b"in flight");
318    }
319
320    #[cfg(unix)]
321    #[test]
322    fn a_planted_staging_symlink_is_refused_and_its_target_is_never_written() {
323        use std::os::unix::fs::PermissionsExt;
324        let dir = scratch();
325        let exe = dir.path().join("pito-work");
326        std::fs::write(&exe, b"old binary").unwrap();
327        let victim = dir.path().join("victim");
328        std::fs::write(&victim, b"victim bytes").unwrap();
329        std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o600)).unwrap();
330        std::os::unix::fs::symlink(&victim, staging_path(&exe)).unwrap();
331        let body = b"the new binary bytes".to_vec();
332        let url = serve_once(body.clone());
333        let binary = binary_for(&body, url);
334
335        let outcome = install(&binary, &exe);
336
337        assert!(matches!(outcome, Err(RenewError::StageFailed { .. })));
338        assert_eq!(std::fs::read(&victim).unwrap(), b"victim bytes");
339        let mode = std::fs::metadata(&victim).unwrap().permissions().mode();
340        assert_eq!(mode & 0o777, 0o600);
341        assert_eq!(std::fs::read(&exe).unwrap(), b"old binary");
342        assert!(
343            std::fs::symlink_metadata(&exe)
344                .unwrap()
345                .file_type()
346                .is_file()
347        );
348    }
349
350    #[test]
351    fn sibling_binaries_with_one_stem_get_their_own_staging_and_old_paths() {
352        let app = Path::new("bin").join("app");
353        let v2 = Path::new("bin").join("app.v2");
354
355        assert_eq!(staging_path(&v2), Path::new("bin").join("app.v2.staging"));
356        assert_ne!(staging_path(&v2), staging_path(&app));
357        assert_ne!(old_path(&v2), old_path(&app));
358        assert_eq!(
359            staging_path(Path::new("app.exe")),
360            Path::new("app.exe.staging")
361        );
362    }
363
364    #[test]
365    fn clean_old_binary_sweeps_both_leftover_shapes() {
366        let dir = scratch();
367        let exe = dir.path().join("pito-work");
368        std::fs::write(staging_path(&exe), b"staged").unwrap();
369        std::fs::write(old_path(&exe), b"old").unwrap();
370
371        clean_old_binary(&exe);
372
373        assert!(!staging_path(&exe).exists());
374        assert!(!old_path(&exe).exists());
375    }
376
377    #[cfg(unix)]
378    #[test]
379    fn restart_spawns_the_named_binary_detached() {
380        assert!(restart(Path::new("/usr/bin/true")).is_ok());
381        assert!(restart(Path::new("/definitely/not/a/binary")).is_err());
382    }
383
384    #[cfg(windows)]
385    #[test]
386    fn restart_spawns_the_named_binary_detached() {
387        assert!(restart(Path::new("C:\\Windows\\System32\\cmd.exe")).is_ok());
388        assert!(restart(Path::new("C:\\definitely\\not\\a\\binary.exe")).is_err());
389    }
390
391    #[cfg(unix)]
392    #[test]
393    fn an_unwritable_staging_location_is_a_stage_failure_not_a_download_failure() {
394        use std::os::unix::fs::PermissionsExt;
395        let dir = scratch();
396        let sealed = dir.path().join("sealed");
397        std::fs::create_dir(&sealed).unwrap();
398        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o555)).unwrap();
399        let exe = sealed.join("pito-work");
400        let body = b"new bytes".to_vec();
401        let url = serve_once(body.clone());
402        let binary = binary_for(&body, url);
403
404        let outcome = install(&binary, &exe);
405
406        std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
407        assert!(matches!(outcome, Err(RenewError::StageFailed { .. })));
408    }
409
410    #[cfg(windows)]
411    #[test]
412    fn a_failed_second_rename_rolls_the_old_binary_back_so_a_binary_always_exists() {
413        let dir = scratch();
414        let exe = dir.path().join("pito-work.exe");
415        std::fs::write(&exe, b"running binary").unwrap();
416
417        let outcome = swap_in(&exe, &staging_path(&exe));
418
419        assert!(matches!(outcome, Err(RenewError::SwapFailed { .. })));
420        assert_eq!(
421            std::fs::read(&exe).unwrap(),
422            b"running binary",
423            "the swap must never leave the exe path empty"
424        );
425        assert!(!old_path(&exe).exists());
426    }
427
428    #[cfg(windows)]
429    #[test]
430    fn the_windows_swap_parks_the_running_binary_as_old_then_renames_the_staging_in() {
431        let dir = scratch();
432        let exe = dir.path().join("pito-work.exe");
433        std::fs::write(&exe, b"running binary").unwrap();
434        std::fs::write(staging_path(&exe), b"new binary").unwrap();
435
436        swap_in(&exe, &staging_path(&exe)).unwrap();
437
438        assert_eq!(std::fs::read(&exe).unwrap(), b"new binary");
439        assert_eq!(std::fs::read(old_path(&exe)).unwrap(), b"running binary");
440    }
441}