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}