1#[cfg(not(feature = "tracing"))]
2use log::{debug, info};
3#[cfg(feature = "tracing")]
4use tracing::{debug, info};
5
6use crate::{Error, GitHubSource, Release, Source, UpdatePhase, UpdateState, cmd, fs};
7use human_errors::{OptionExt, ResultExt};
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13
14pub struct UpdateManager<S = GitHubSource>
27where
28 S: Source,
29{
30 pub target_application: PathBuf,
33
34 pub source: S,
36
37 launcher: Box<dyn cmd::Launcher + Send + Sync>,
38 filesystem: Box<dyn fs::FileSystem + Send + Sync>,
39 relaunch: cmd::Relaunch,
40}
41
42impl<S> UpdateManager<S>
43where
44 S: Source,
45{
46 pub fn new(source: S) -> Self {
49 Self {
50 target_application: std::env::current_exe().unwrap_or_default(),
51 source,
52 launcher: cmd::default(),
53 filesystem: fs::default(),
54 relaunch: cmd::Relaunch::default(),
55 }
56 }
57
58 pub fn with_target_application(mut self, target_application: PathBuf) -> Self {
61 self.target_application = target_application;
62 self
63 }
64
65 pub fn with_relaunch_arg(mut self, arg: impl Into<OsString>) -> Self {
74 self.relaunch.args.push(arg.into());
75 self
76 }
77
78 pub fn with_relaunch_args<I, A>(mut self, args: I) -> Self
81 where
82 I: IntoIterator<Item = A>,
83 A: Into<OsString>,
84 {
85 self.relaunch.args.extend(args.into_iter().map(Into::into));
86 self
87 }
88
89 pub fn with_relaunch_env(
94 mut self,
95 key: impl Into<OsString>,
96 value: impl Into<OsString>,
97 ) -> Self {
98 self.relaunch.envs.push((key.into(), value.into()));
99 self
100 }
101
102 pub fn with_relaunch_envs<I, K, V>(mut self, envs: I) -> Self
105 where
106 I: IntoIterator<Item = (K, V)>,
107 K: Into<OsString>,
108 V: Into<OsString>,
109 {
110 self.relaunch
111 .envs
112 .extend(envs.into_iter().map(|(k, v)| (k.into(), v.into())));
113 self
114 }
115
116 #[cfg(test)]
117 pub(crate) fn with_mock_launcher<M: FnMut(&mut cmd::MockLauncher)>(
118 mut self,
119 mut setup: M,
120 ) -> Self {
121 let mut mock = cmd::MockLauncher::new();
122 setup(&mut mock);
123 self.launcher = Box::new(mock);
124 self
125 }
126
127 #[cfg(test)]
128 pub(crate) fn with_mock_fs<M: FnMut(&mut fs::MockFileSystem)>(mut self, mut setup: M) -> Self {
129 let mut mock = fs::MockFileSystem::new();
130 setup(&mut mock);
131 self.filesystem = Box::new(mock);
132 self
133 }
134
135 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
137 pub async fn get_releases(&self) -> Result<Vec<Release>, Error> {
138 self.source.get_releases().await
139 }
140
141 #[cfg_attr(
149 feature = "tracing",
150 tracing::instrument(skip(self, release), fields(release = %release.id, version = %release.version))
151 )]
152 pub async fn update(&self, release: &Release) -> Result<bool, Error> {
153 let state = UpdateState {
154 target_application: Some(self.target_application.clone()),
155 temporary_application: Some(
156 self.filesystem
157 .get_temp_app_path(&self.target_application, release),
158 ),
159 trace_context: None,
160 phase: UpdatePhase::Prepare,
161 };
162
163 let app = state.temporary_application.clone().ok_or_system_err(
164 "A temporary application path was not provided and the update cannot proceed (prepare -> replace phase).",
165 &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
166 )?;
167
168 let variant = release.get_variant().ok_or_user_err(
169 format!(
170 "No release asset for {} matched the configured artifact pattern.",
171 release.id
172 ),
173 &["Check that your project publishes a release asset matching the pattern you configured for this platform."],
174 )?;
175
176 {
177 info!(
178 "Checking whether the application binary ({}) is writable by the current user.",
179 self.target_application.display()
180 );
181 let metadata = tokio::fs::metadata(&self.target_application).await.wrap_user_err(
182 format!(
183 "Failed to read the current file state of the application binary ({}).",
184 self.target_application.display()
185 ),
186 &[
187 "Please ensure that the application binary exists and that this tool has permission to read and write to it.",
188 "Try running the update command again with elevated permissions.",
189 ],
190 )?;
191
192 if metadata.permissions().readonly() {
193 return Err(human_errors::user(
194 "The application binary is read-only, so it cannot be replaced by the update.",
195 &{
196 #[cfg(windows)]
197 {
198 [
199 "Make sure the binary is writable, or try running this command in an administrative console (Win+X, A).",
200 ]
201 }
202
203 #[cfg(unix)]
204 {
205 [
206 "Make sure the binary is writable, or try running this command as root (e.g. with `sudo`).",
207 ]
208 }
209 },
210 ));
211 }
212 }
213
214 {
215 info!(
216 "Downloading release binary for {} to a temporary location ({}).",
217 release.version,
218 app.display()
219 );
220 let mut app_file = std::fs::File::create(&app).wrap_user_err(
221 format!(
222 "Could not create the new application file '{}' due to an OS-level error.",
223 app.display()
224 ),
225 &["Check that this tool has permission to create and write to this file and that the parent directory exists."],
226 )?;
227 if let Err(e) = self
228 .source
229 .get_binary(release, variant, &mut app_file)
230 .await
231 {
232 drop(app_file);
234 let _ = std::fs::remove_file(&app);
235 return Err(e);
236 }
237
238 debug!("Preparing the downloaded application file for execution.");
239 self.prepare_app_file(&app)?;
240 }
241
242 self.resume(&state).await
243 }
244
245 pub async fn resume(&self, state: &UpdateState) -> Result<bool, Error> {
252 #[cfg(feature = "tracing")]
253 {
254 use tracing::Instrument;
255
256 let span = tracing::info_span!("resume", phase = %state.phase);
257 state.adopt_trace_context(&span);
262 self.dispatch(state).instrument(span).await
263 }
264
265 #[cfg(not(feature = "tracing"))]
266 {
267 self.dispatch(state).await
268 }
269 }
270
271 async fn dispatch(&self, state: &UpdateState) -> Result<bool, Error> {
272 match state.phase {
273 UpdatePhase::NoUpdate => Ok(false),
274 UpdatePhase::Prepare => self.prepare(state).await,
275 UpdatePhase::Replace => self.replace(state).await,
276 UpdatePhase::Cleanup => self.cleanup(state).await,
277 }
278 }
279
280 pub async fn resume_from_arg(&self, state_json: &str) -> Result<bool, Error> {
284 let state: UpdateState = serde_json::from_str(state_json).wrap_system_err(
285 "Could not deserialize the update state which was passed on the command line.",
286 &["Please report this issue to the application's maintainers and use the manual update process until it is resolved."],
287 )?;
288
289 info!("Resuming update in the '{}' phase.", state.phase);
290 self.resume(&state).await
291 }
292
293 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
294 async fn prepare(&self, state: &UpdateState) -> Result<bool, Error> {
295 let mut next_state = state.for_phase(UpdatePhase::Replace);
296 next_state.capture_trace_context();
299 let update_source = state.temporary_application.clone().ok_or_system_err(
300 "Could not launch the new application version to continue the update process (prepare -> replace phase).",
301 &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
302 )?;
303
304 info!(
305 "Launching the temporary release binary to perform the 'replace' phase of the update."
306 );
307 self.launch(&update_source, &next_state)?;
308
309 Ok(true)
310 }
311
312 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
313 async fn replace(&self, state: &UpdateState) -> Result<bool, Error> {
314 let update_source = state.temporary_application.clone().ok_or_system_err(
315 "Could not locate the temporary update files needed to complete the update process (replace phase).",
316 &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
317 )?;
318 let update_target = state.target_application.clone().ok_or_system_err(
319 "Could not locate the application which was meant to be updated (replace phase).",
320 &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
321 )?;
322
323 info!("Removing the original application binary to avoid conflicts with open handles.");
324 self.filesystem.delete_file(&update_target).await?;
325
326 info!("Replacing the original application binary with the temporary release binary.");
327 self.filesystem
328 .copy_file(&update_source, &update_target)
329 .await?;
330
331 info!("Launching the updated application to perform the 'cleanup' phase of the update.");
332 let mut next_state = state.for_phase(UpdatePhase::Cleanup);
333 next_state.capture_trace_context();
335 self.launch(&update_target, &next_state)?;
336
337 Ok(true)
338 }
339
340 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
341 async fn cleanup(&self, state: &UpdateState) -> Result<bool, Error> {
342 let update_source = state.temporary_application.clone().ok_or_system_err(
343 "Could not locate the temporary update files needed to complete the update process (cleanup phase).",
344 &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
345 )?;
346
347 info!("Removing the temporary update application binary.");
348 self.filesystem.delete_file(&update_source).await?;
349
350 Ok(true)
351 }
352
353 #[cfg(unix)]
354 fn prepare_app_file(&self, file: &Path) -> Result<(), Error> {
355 let mut perms = std::fs::metadata(file)
356 .wrap_user_err(
357 format!(
358 "Could not read the permissions of '{}' due to an OS-level error.",
359 file.display()
360 ),
361 &["Check that this tool has permission to read this file and that the parent directory exists."],
362 )?
363 .permissions();
364
365 debug!("Setting executable permissions (0o755) on the downloaded application binary.");
366 perms.set_mode(0o755);
367 std::fs::set_permissions(file, perms).wrap_user_err(
368 format!(
369 "Could not set executable permissions on '{}' due to an OS-level error.",
370 file.display()
371 ),
372 &["Check that this tool has permission to modify this file and that the parent directory exists."],
373 )?;
374
375 Ok(())
376 }
377
378 #[cfg(not(unix))]
379 fn prepare_app_file(&self, _file: &Path) -> Result<(), Error> {
380 Ok(())
381 }
382
383 fn launch(&self, app_path: &Path, state: &UpdateState) -> Result<(), Error> {
384 self.launcher.launch(app_path, state, &self.relaunch)
385 }
386}
387
388impl<S> Default for UpdateManager<S>
389where
390 S: Source,
391{
392 fn default() -> Self {
393 Self::new(S::default())
394 }
395}
396
397impl<S> std::fmt::Debug for UpdateManager<S>
398where
399 S: Source,
400{
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 write!(f, "{:?}", &self.source)
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use tempfile::tempdir;
410 use wiremock::matchers::{method, path, path_regex};
411 use wiremock::{Mock, MockServer, ResponseTemplate};
412
413 const RELEASES_JSON: &str = r#"[
414 {
415 "name": "Version 2.0.0",
416 "tag_name": "v2.0.0",
417 "body": "Example Release",
418 "prerelease": false,
419 "assets": [
420 { "name": "update-windows-amd64.exe" },
421 { "name": "update-linux-amd64" },
422 { "name": "update-darwin-arm64" }
423 ]
424 }
425 ]"#;
426
427 #[tokio::test]
428 async fn test_update() {
429 let server = MockServer::start().await;
430 Mock::given(method("GET"))
431 .and(path("/repos/sierrasoftworks/update-rs/releases"))
432 .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
433 .mount(&server)
434 .await;
435 Mock::given(method("GET"))
436 .and(path_regex(
437 r"^/sierrasoftworks/update-rs/releases/download/",
438 ))
439 .respond_with(ResponseTemplate::new(200).set_body_string("testdata"))
440 .mount(&server)
441 .await;
442
443 let temp = tempdir().unwrap();
444 let app_path = temp.path().join("app");
445 let temp_app_path = temp.path().join("app-temp");
446 std::fs::write(&app_path, "Pre-Update").unwrap();
447
448 let source = GitHubSource::new("sierrasoftworks/update-rs", "update-linux-amd64")
450 .with_github_endpoints(&server.uri(), &server.uri())
451 .with_release_tag_prefix("v");
452
453 let manager = UpdateManager::new(source)
454 .with_target_application(app_path.clone())
455 .with_mock_launcher(|mock| {
456 let temp_app_path = temp_app_path.clone();
457 mock.expect_launch()
458 .once()
459 .withf(move |p, s, _| p == temp_app_path && s.phase == UpdatePhase::Replace)
460 .returning(|_, _, _| Ok(()));
461 })
462 .with_mock_fs(|mock| {
463 mock.expect_get_temp_app_path()
464 .once()
465 .return_const(temp_app_path.clone());
466 });
467
468 let releases = manager
469 .get_releases()
470 .await
471 .expect("we should receive a release entry");
472 let latest =
473 Release::get_latest(releases.iter()).expect("we should receive a latest release entry");
474
475 let has_update = manager
476 .update(latest)
477 .await
478 .expect("the update operation should succeed");
479
480 assert!(has_update, "the update should be applied");
481 }
482
483 #[tokio::test]
484 async fn test_update_resume() {
485 let temp = tempdir().unwrap();
486 let app_path = temp.path().join("app");
487 let temp_app_path = temp.path().join("app-temp");
488 std::fs::write(&app_path, "original").unwrap();
489 std::fs::write(&temp_app_path, "new").unwrap();
490
491 let manager = UpdateManager::<GitHubSource>::default()
492 .with_target_application(app_path.clone())
493 .with_mock_launcher(|mock| {
494 let app_path = app_path.clone();
495 mock.expect_launch()
496 .once()
497 .withf(move |p, s, _| p == app_path && s.phase == UpdatePhase::Cleanup)
498 .returning(|_, _, _| Ok(()));
499 })
500 .with_mock_fs(|mock| {
501 let app_path = app_path.clone();
502 let app_path_for_copy = app_path.clone();
503 let temp_app_path = temp_app_path.clone();
504 mock.expect_get_temp_app_path().never();
505 mock.expect_delete_file()
506 .once()
507 .withf(move |p| p == app_path)
508 .returning(|_| Ok(()));
509 mock.expect_copy_file()
510 .once()
511 .withf(move |src, dst| src == temp_app_path && dst == app_path_for_copy)
512 .returning(|_, _| Ok(()));
513 });
514
515 let state = UpdateState {
516 phase: UpdatePhase::Replace,
517 target_application: Some(app_path.clone()),
518 temporary_application: Some(temp_app_path.clone()),
519 trace_context: None,
520 };
521
522 let has_update = manager
523 .resume(&state)
524 .await
525 .expect("the update operation should succeed");
526
527 assert!(has_update, "the update should be applied");
528 }
529
530 #[tokio::test]
531 async fn resume_forwards_custom_relaunch_customization() {
532 let temp = tempdir().unwrap();
533 let app_path = temp.path().join("app");
534 let temp_app_path = temp.path().join("app-temp");
535 std::fs::write(&app_path, "original").unwrap();
536 std::fs::write(&temp_app_path, "new").unwrap();
537
538 let manager = UpdateManager::<GitHubSource>::default()
539 .with_target_application(app_path.clone())
540 .with_relaunch_args(["--trace-context", "ctx"])
541 .with_relaunch_env("APP_UPDATING", "1")
542 .with_mock_launcher(|mock| {
543 mock.expect_launch()
544 .once()
545 .withf(|_app, _state, relaunch| {
546 relaunch.args == [OsString::from("--trace-context"), OsString::from("ctx")]
547 && relaunch.envs
548 == [(OsString::from("APP_UPDATING"), OsString::from("1"))]
549 })
550 .returning(|_, _, _| Ok(()));
551 })
552 .with_mock_fs(|mock| {
553 let app_path = app_path.clone();
554 let app_path_for_copy = app_path.clone();
555 let temp_app_path = temp_app_path.clone();
556 mock.expect_get_temp_app_path().never();
557 mock.expect_delete_file()
558 .once()
559 .withf(move |p| p == app_path)
560 .returning(|_| Ok(()));
561 mock.expect_copy_file()
562 .once()
563 .withf(move |src, dst| src == temp_app_path && dst == app_path_for_copy)
564 .returning(|_, _| Ok(()));
565 });
566
567 let state = UpdateState {
568 phase: UpdatePhase::Replace,
569 target_application: Some(app_path.clone()),
570 temporary_application: Some(temp_app_path.clone()),
571 trace_context: None,
572 };
573
574 manager
575 .resume(&state)
576 .await
577 .expect("the update operation should succeed");
578 }
579
580 #[tokio::test]
581 async fn test_update_cleanup() {
582 let temp = tempdir().unwrap();
583 let app_path = temp.path().join("app");
584 let temp_app_path = temp.path().join("app-temp");
585 std::fs::write(&app_path, "original").unwrap();
586 std::fs::write(&temp_app_path, "new").unwrap();
587
588 let manager = UpdateManager::<GitHubSource>::default()
589 .with_target_application(app_path.clone())
590 .with_mock_launcher(|mock| {
591 mock.expect_spawn().never();
592 })
593 .with_mock_fs(|mock| {
594 let temp_app_path = temp_app_path.clone();
595 mock.expect_get_temp_app_path().never();
596 mock.expect_delete_file()
597 .once()
598 .withf(move |p| p == temp_app_path)
599 .returning(|p| {
600 std::fs::remove_file(p).expect("we should be able to delete the path");
601 Ok(())
602 });
603 });
604
605 let state = UpdateState {
606 phase: UpdatePhase::Cleanup,
607 target_application: Some(app_path.clone()),
608 temporary_application: Some(temp_app_path.clone()),
609 trace_context: None,
610 };
611
612 let has_update = manager
613 .resume(&state)
614 .await
615 .expect("the update operation should succeed");
616
617 assert!(has_update, "the update should be applied");
618 assert!(app_path.exists(), "the app should still be present");
619 assert!(
620 !temp_app_path.exists(),
621 "the temp app should have been removed"
622 );
623 }
624}