Skip to main content

update_rs/
manager.rs

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