Skip to main content

update_rs/
manager.rs

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
14/// Drives the three-phase, in-place self-update of an application binary.
15///
16/// An `UpdateManager` lists the releases offered by its [`Source`], downloads
17/// the asset the source selected for this platform, and then walks through the
18/// `prepare → replace → cleanup` phases by relaunching the application between
19/// each phase (see the [crate-level docs](crate) and [`RESUME_FLAG`](crate::RESUME_FLAG)).
20///
21/// Construct one with [`UpdateManager::new`] (which targets the currently
22/// running executable) and customise it with
23/// [`with_target_application`](Self::with_target_application) if needed. Which
24/// release asset is downloaded is configured on the [`Source`] — see
25/// [`GitHubSource`].
26pub struct UpdateManager<S = GitHubSource>
27where
28    S: Source,
29{
30    /// The application binary which will be replaced by the update. Defaults to
31    /// the currently running executable.
32    pub target_application: PathBuf,
33
34    /// The source releases are listed and downloaded from.
35    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    /// Create a manager which will update the currently running executable
47    /// using the provided release `source`.
48    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    /// Override the application binary which will be updated (defaults to the
59    /// currently running executable).
60    pub fn with_target_application(mut self, target_application: PathBuf) -> Self {
61        self.target_application = target_application;
62        self
63    }
64
65    /// Append a custom command-line argument passed to every process the updater
66    /// relaunches, in addition to the library's own
67    /// [`RESUME_FLAG`](crate::RESUME_FLAG) and serialized state.
68    ///
69    /// Useful for threading application-specific context through an update — for
70    /// example a `--trace-context` value, or a flag that tweaks behaviour while
71    /// the update is in progress. Call it repeatedly to add several arguments;
72    /// they are appended in order, after the resume flag.
73    pub fn with_relaunch_arg(mut self, arg: impl Into<OsString>) -> Self {
74        self.relaunch.args.push(arg.into());
75        self
76    }
77
78    /// Append several custom command-line arguments to every relaunched process.
79    /// See [`with_relaunch_arg`](Self::with_relaunch_arg).
80    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    /// Set a custom environment variable on every process the updater relaunches.
90    ///
91    /// Call it repeatedly to set several variables. They are added to (not
92    /// replacing) the inherited environment of the relaunched process.
93    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    /// Set several custom environment variables on every relaunched process.
103    /// See [`with_relaunch_env`](Self::with_relaunch_env).
104    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    /// List the releases available from the configured [`Source`].
136    #[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    /// Begin updating the [`target_application`](Self::target_application) to
142    /// the provided release.
143    ///
144    /// This downloads the new binary, then launches it to continue the update
145    /// in a separate process. Returns `Ok(true)` if an update was started, in
146    /// which case the caller should exit promptly so the relaunched process can
147    /// replace the running binary.
148    #[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                // Don't leave a partial or failed-verification download behind.
233                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    /// Resume an update from a previously serialized [`UpdateState`].
246    ///
247    /// A consuming application calls this (usually via
248    /// [`resume_from_arg`](Self::resume_from_arg)) when it is relaunched with
249    /// the [`RESUME_FLAG`](crate::RESUME_FLAG). Returns `Ok(true)` when a phase
250    /// was processed and the process should exit.
251    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            // Re-parent this span onto the trace carried from the phase that
258            // relaunched us *before* it is entered, so all the work in this
259            // process continues that distributed trace. A no-op without the
260            // `opentelemetry` feature or a carried context.
261            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    /// Deserialize an [`UpdateState`] from the JSON argument that follows the
281    /// [`RESUME_FLAG`](crate::RESUME_FLAG) on the command line, then
282    /// [`resume`](Self::resume) the update from it.
283    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        // Hand the active trace context to the next process so the update's
297        // phases stitch together into a single distributed trace.
298        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        // Carry the active trace context forward into the final phase too.
334        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        // A fixed pattern so the test is independent of the host platform.
449        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}