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::path::{Path, PathBuf};
9
10#[cfg(unix)]
11use std::os::unix::fs::PermissionsExt;
12
13/// Drives the three-phase, in-place self-update of an application binary.
14///
15/// An `UpdateManager` lists the releases offered by its [`Source`], downloads
16/// the asset the source selected for this platform, and then walks through the
17/// `prepare → replace → cleanup` phases by relaunching the application between
18/// each phase (see the [crate-level docs](crate) and [`RESUME_FLAG`](crate::RESUME_FLAG)).
19///
20/// Construct one with [`UpdateManager::new`] (which targets the currently
21/// running executable) and customise it with
22/// [`with_target_application`](Self::with_target_application) if needed. Which
23/// release asset is downloaded is configured on the [`Source`] — see
24/// [`GitHubSource`].
25pub struct UpdateManager<S = GitHubSource>
26where
27    S: Source,
28{
29    /// The application binary which will be replaced by the update. Defaults to
30    /// the currently running executable.
31    pub target_application: PathBuf,
32
33    /// The source releases are listed and downloaded from.
34    pub source: S,
35
36    launcher: Box<dyn cmd::Launcher + Send + Sync>,
37    filesystem: Box<dyn fs::FileSystem + Send + Sync>,
38}
39
40impl<S> UpdateManager<S>
41where
42    S: Source,
43{
44    /// Create a manager which will update the currently running executable
45    /// using the provided release `source`.
46    pub fn new(source: S) -> Self {
47        Self {
48            target_application: std::env::current_exe().unwrap_or_default(),
49            source,
50            launcher: cmd::default(),
51            filesystem: fs::default(),
52        }
53    }
54
55    /// Override the application binary which will be updated (defaults to the
56    /// currently running executable).
57    pub fn with_target_application(mut self, target_application: PathBuf) -> Self {
58        self.target_application = target_application;
59        self
60    }
61
62    /// Replace the [`Launcher`](crate::Launcher) used to relaunch the application
63    /// between update phases.
64    ///
65    /// The default ([`DefaultLauncher`](crate::DefaultLauncher)) relaunches with
66    /// the library's [`RESUME_FLAG`](crate::RESUME_FLAG) and spawns a detached
67    /// child. Provide your own to change how the relaunch command is built — for
68    /// example to pass the update state via a sub-command, or to thread your own
69    /// arguments and environment variables through to the next process.
70    pub fn with_launcher(mut self, launcher: Box<dyn cmd::Launcher + Send + Sync>) -> Self {
71        self.launcher = launcher;
72        self
73    }
74
75    #[cfg(test)]
76    pub(crate) fn with_mock_launcher<M: FnMut(&mut cmd::MockLauncher)>(
77        mut self,
78        mut setup: M,
79    ) -> Self {
80        let mut mock = cmd::MockLauncher::new();
81        setup(&mut mock);
82        self.launcher = Box::new(mock);
83        self
84    }
85
86    #[cfg(test)]
87    pub(crate) fn with_mock_fs<M: FnMut(&mut fs::MockFileSystem)>(mut self, mut setup: M) -> Self {
88        let mut mock = fs::MockFileSystem::new();
89        setup(&mut mock);
90        self.filesystem = Box::new(mock);
91        self
92    }
93
94    /// List the releases available from the configured [`Source`].
95    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
96    pub async fn get_releases(&self) -> Result<Vec<Release>, Error> {
97        self.source.get_releases().await
98    }
99
100    /// Begin updating the [`target_application`](Self::target_application) to
101    /// the provided release.
102    ///
103    /// This downloads the new binary, then launches it to continue the update
104    /// in a separate process. Returns `Ok(true)` if an update was started, in
105    /// which case the caller should exit promptly so the relaunched process can
106    /// replace the running binary.
107    #[cfg_attr(
108        feature = "tracing",
109        tracing::instrument(skip(self, release), fields(release = %release.id, version = %release.version))
110    )]
111    pub async fn update(&self, release: &Release) -> Result<bool, Error> {
112        let state = UpdateState {
113            target_application: Some(self.target_application.clone()),
114            temporary_application: Some(
115                self.filesystem
116                    .get_temp_app_path(&self.target_application, release),
117            ),
118            trace_context: None,
119            phase: UpdatePhase::Prepare,
120        };
121
122        let app = state.temporary_application.clone().ok_or_system_err(
123            "A temporary application path was not provided and the update cannot proceed (prepare -> replace phase).",
124            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
125        )?;
126
127        let variant = release.get_variant().ok_or_user_err(
128            format!(
129                "No release asset for {} matched the configured artifact pattern.",
130                release.id
131            ),
132            &["Check that your project publishes a release asset matching the pattern you configured for this platform."],
133        )?;
134
135        {
136            info!(
137                "Checking whether the application binary ({}) is writable by the current user.",
138                self.target_application.display()
139            );
140            let metadata = tokio::fs::metadata(&self.target_application).await.wrap_user_err(
141                format!(
142                    "Failed to read the current file state of the application binary ({}).",
143                    self.target_application.display()
144                ),
145                &[
146                    "Please ensure that the application binary exists and that this tool has permission to read and write to it.",
147                    "Try running the update command again with elevated permissions.",
148                ],
149            )?;
150
151            if metadata.permissions().readonly() {
152                return Err(human_errors::user(
153                    "The application binary is read-only, so it cannot be replaced by the update.",
154                    &{
155                        #[cfg(windows)]
156                        {
157                            [
158                                "Make sure the binary is writable, or try running this command in an administrative console (Win+X, A).",
159                            ]
160                        }
161
162                        #[cfg(unix)]
163                        {
164                            [
165                                "Make sure the binary is writable, or try running this command as root (e.g. with `sudo`).",
166                            ]
167                        }
168                    },
169                ));
170            }
171        }
172
173        {
174            info!(
175                "Downloading release binary for {} to a temporary location ({}).",
176                release.version,
177                app.display()
178            );
179            let mut app_file = std::fs::File::create(&app).wrap_user_err(
180                format!(
181                    "Could not create the new application file '{}' due to an OS-level error.",
182                    app.display()
183                ),
184                &["Check that this tool has permission to create and write to this file and that the parent directory exists."],
185            )?;
186            if let Err(e) = self
187                .source
188                .get_binary(release, variant, &mut app_file)
189                .await
190            {
191                // Don't leave a partial or failed-verification download behind.
192                drop(app_file);
193                let _ = std::fs::remove_file(&app);
194                return Err(e);
195            }
196
197            debug!("Preparing the downloaded application file for execution.");
198            self.prepare_app_file(&app)?;
199        }
200
201        self.resume(&state).await
202    }
203
204    /// Resume an update from a previously serialized [`UpdateState`].
205    ///
206    /// A consuming application calls this (usually via
207    /// [`resume_from_arg`](Self::resume_from_arg)) when it is relaunched with
208    /// the [`RESUME_FLAG`](crate::RESUME_FLAG). Returns `Ok(true)` when a phase
209    /// was processed and the process should exit.
210    pub async fn resume(&self, state: &UpdateState) -> Result<bool, Error> {
211        #[cfg(feature = "tracing")]
212        {
213            use tracing::Instrument;
214
215            let span = tracing::info_span!("resume", phase = %state.phase);
216            // Re-parent this span onto the trace carried from the phase that
217            // relaunched us *before* it is entered, so all the work in this
218            // process continues that distributed trace. A no-op without the
219            // `opentelemetry` feature or a carried context.
220            state.adopt_trace_context(&span);
221            self.dispatch(state).instrument(span).await
222        }
223
224        #[cfg(not(feature = "tracing"))]
225        {
226            self.dispatch(state).await
227        }
228    }
229
230    async fn dispatch(&self, state: &UpdateState) -> Result<bool, Error> {
231        match state.phase {
232            UpdatePhase::NoUpdate => Ok(false),
233            UpdatePhase::Prepare => self.prepare(state).await,
234            UpdatePhase::Replace => self.replace(state).await,
235            UpdatePhase::Cleanup => self.cleanup(state).await,
236        }
237    }
238
239    /// Deserialize an [`UpdateState`] from the JSON argument that follows the
240    /// [`RESUME_FLAG`](crate::RESUME_FLAG) on the command line, then
241    /// [`resume`](Self::resume) the update from it.
242    pub async fn resume_from_arg(&self, state_json: &str) -> Result<bool, Error> {
243        let state: UpdateState = serde_json::from_str(state_json).wrap_system_err(
244            "Could not deserialize the update state which was passed on the command line.",
245            &["Please report this issue to the application's maintainers and use the manual update process until it is resolved."],
246        )?;
247
248        info!("Resuming update in the '{}' phase.", state.phase);
249        self.resume(&state).await
250    }
251
252    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
253    async fn prepare(&self, state: &UpdateState) -> Result<bool, Error> {
254        let mut next_state = state.for_phase(UpdatePhase::Replace);
255        // Hand the active trace context to the next process so the update's
256        // phases stitch together into a single distributed trace.
257        next_state.capture_trace_context();
258        let update_source = state.temporary_application.clone().ok_or_system_err(
259            "Could not launch the new application version to continue the update process (prepare -> replace phase).",
260            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
261        )?;
262
263        info!(
264            "Launching the temporary release binary to perform the 'replace' phase of the update."
265        );
266        self.launch(&update_source, &next_state)?;
267
268        Ok(true)
269    }
270
271    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
272    async fn replace(&self, state: &UpdateState) -> Result<bool, Error> {
273        let update_source = state.temporary_application.clone().ok_or_system_err(
274            "Could not locate the temporary update files needed to complete the update process (replace phase).",
275            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
276        )?;
277        let update_target = state.target_application.clone().ok_or_system_err(
278            "Could not locate the application which was meant to be updated (replace phase).",
279            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
280        )?;
281
282        info!("Removing the original application binary to avoid conflicts with open handles.");
283        self.filesystem.delete_file(&update_target).await?;
284
285        info!("Replacing the original application binary with the temporary release binary.");
286        self.filesystem
287            .copy_file(&update_source, &update_target)
288            .await?;
289
290        info!("Launching the updated application to perform the 'cleanup' phase of the update.");
291        let mut next_state = state.for_phase(UpdatePhase::Cleanup);
292        // Carry the active trace context forward into the final phase too.
293        next_state.capture_trace_context();
294        self.launch(&update_target, &next_state)?;
295
296        Ok(true)
297    }
298
299    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, state)))]
300    async fn cleanup(&self, state: &UpdateState) -> Result<bool, Error> {
301        let update_source = state.temporary_application.clone().ok_or_system_err(
302            "Could not locate the temporary update files needed to complete the update process (cleanup phase).",
303            &["Please report this issue to the application's maintainers, or try updating manually by downloading the latest release yourself."],
304        )?;
305
306        info!("Removing the temporary update application binary.");
307        self.filesystem.delete_file(&update_source).await?;
308
309        Ok(true)
310    }
311
312    #[cfg(unix)]
313    fn prepare_app_file(&self, file: &Path) -> Result<(), Error> {
314        let mut perms = std::fs::metadata(file)
315            .wrap_user_err(
316                format!(
317                    "Could not read the permissions of '{}' due to an OS-level error.",
318                    file.display()
319                ),
320                &["Check that this tool has permission to read this file and that the parent directory exists."],
321            )?
322            .permissions();
323
324        debug!("Setting executable permissions (0o755) on the downloaded application binary.");
325        perms.set_mode(0o755);
326        std::fs::set_permissions(file, perms).wrap_user_err(
327            format!(
328                "Could not set executable permissions on '{}' due to an OS-level error.",
329                file.display()
330            ),
331            &["Check that this tool has permission to modify this file and that the parent directory exists."],
332        )?;
333
334        Ok(())
335    }
336
337    #[cfg(not(unix))]
338    fn prepare_app_file(&self, _file: &Path) -> Result<(), Error> {
339        Ok(())
340    }
341
342    fn launch(&self, app_path: &Path, state: &UpdateState) -> Result<(), Error> {
343        self.launcher.launch(app_path, state)
344    }
345}
346
347impl<S> Default for UpdateManager<S>
348where
349    S: Source,
350{
351    fn default() -> Self {
352        Self::new(S::default())
353    }
354}
355
356impl<S> std::fmt::Debug for UpdateManager<S>
357where
358    S: Source,
359{
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        write!(f, "{:?}", &self.source)
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use tempfile::tempdir;
369    use wiremock::matchers::{method, path, path_regex};
370    use wiremock::{Mock, MockServer, ResponseTemplate};
371
372    const RELEASES_JSON: &str = r#"[
373        {
374            "name": "Version 2.0.0",
375            "tag_name": "v2.0.0",
376            "body": "Example Release",
377            "prerelease": false,
378            "assets": [
379                { "name": "update-windows-amd64.exe" },
380                { "name": "update-linux-amd64" },
381                { "name": "update-darwin-arm64" }
382            ]
383        }
384    ]"#;
385
386    #[tokio::test]
387    async fn test_update() {
388        let server = MockServer::start().await;
389        Mock::given(method("GET"))
390            .and(path("/repos/sierrasoftworks/update-rs/releases"))
391            .respond_with(ResponseTemplate::new(200).set_body_string(RELEASES_JSON))
392            .mount(&server)
393            .await;
394        Mock::given(method("GET"))
395            .and(path_regex(
396                r"^/sierrasoftworks/update-rs/releases/download/",
397            ))
398            .respond_with(ResponseTemplate::new(200).set_body_string("testdata"))
399            .mount(&server)
400            .await;
401
402        let temp = tempdir().unwrap();
403        let app_path = temp.path().join("app");
404        let temp_app_path = temp.path().join("app-temp");
405        std::fs::write(&app_path, "Pre-Update").unwrap();
406
407        // A fixed pattern so the test is independent of the host platform.
408        let source = GitHubSource::new("sierrasoftworks/update-rs", "update-linux-amd64")
409            .with_github_endpoints(&server.uri(), &server.uri())
410            .with_release_tag_prefix("v");
411
412        let manager = UpdateManager::new(source)
413            .with_target_application(app_path.clone())
414            .with_mock_launcher(|mock| {
415                let temp_app_path = temp_app_path.clone();
416                mock.expect_launch()
417                    .once()
418                    .withf(move |p, s| p == temp_app_path && s.phase == UpdatePhase::Replace)
419                    .returning(|_, _| Ok(()));
420            })
421            .with_mock_fs(|mock| {
422                mock.expect_get_temp_app_path()
423                    .once()
424                    .return_const(temp_app_path.clone());
425            });
426
427        let releases = manager
428            .get_releases()
429            .await
430            .expect("we should receive a release entry");
431        let latest =
432            Release::get_latest(releases.iter()).expect("we should receive a latest release entry");
433
434        let has_update = manager
435            .update(latest)
436            .await
437            .expect("the update operation should succeed");
438
439        assert!(has_update, "the update should be applied");
440    }
441
442    #[tokio::test]
443    async fn test_update_resume() {
444        let temp = tempdir().unwrap();
445        let app_path = temp.path().join("app");
446        let temp_app_path = temp.path().join("app-temp");
447        std::fs::write(&app_path, "original").unwrap();
448        std::fs::write(&temp_app_path, "new").unwrap();
449
450        let manager = UpdateManager::<GitHubSource>::default()
451            .with_target_application(app_path.clone())
452            .with_mock_launcher(|mock| {
453                let app_path = app_path.clone();
454                mock.expect_launch()
455                    .once()
456                    .withf(move |p, s| p == app_path && s.phase == UpdatePhase::Cleanup)
457                    .returning(|_, _| Ok(()));
458            })
459            .with_mock_fs(|mock| {
460                let app_path = app_path.clone();
461                let app_path_for_copy = app_path.clone();
462                let temp_app_path = temp_app_path.clone();
463                mock.expect_get_temp_app_path().never();
464                mock.expect_delete_file()
465                    .once()
466                    .withf(move |p| p == app_path)
467                    .returning(|_| Ok(()));
468                mock.expect_copy_file()
469                    .once()
470                    .withf(move |src, dst| src == temp_app_path && dst == app_path_for_copy)
471                    .returning(|_, _| Ok(()));
472            });
473
474        let state = UpdateState {
475            phase: UpdatePhase::Replace,
476            target_application: Some(app_path.clone()),
477            temporary_application: Some(temp_app_path.clone()),
478            trace_context: None,
479        };
480
481        let has_update = manager
482            .resume(&state)
483            .await
484            .expect("the update operation should succeed");
485
486        assert!(has_update, "the update should be applied");
487    }
488
489    #[tokio::test]
490    async fn test_update_cleanup() {
491        let temp = tempdir().unwrap();
492        let app_path = temp.path().join("app");
493        let temp_app_path = temp.path().join("app-temp");
494        std::fs::write(&app_path, "original").unwrap();
495        std::fs::write(&temp_app_path, "new").unwrap();
496
497        let manager = UpdateManager::<GitHubSource>::default()
498            .with_target_application(app_path.clone())
499            .with_mock_launcher(|mock| {
500                mock.expect_spawn().never();
501            })
502            .with_mock_fs(|mock| {
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 == temp_app_path)
508                    .returning(|p| {
509                        std::fs::remove_file(p).expect("we should be able to delete the path");
510                        Ok(())
511                    });
512            });
513
514        let state = UpdateState {
515            phase: UpdatePhase::Cleanup,
516            target_application: Some(app_path.clone()),
517            temporary_application: Some(temp_app_path.clone()),
518            trace_context: None,
519        };
520
521        let has_update = manager
522            .resume(&state)
523            .await
524            .expect("the update operation should succeed");
525
526        assert!(has_update, "the update should be applied");
527        assert!(app_path.exists(), "the app should still be present");
528        assert!(
529            !temp_app_path.exists(),
530            "the temp app should have been removed"
531        );
532    }
533}