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
9pub struct UpdateManager<S = GitHubSource>
22where
23 S: Source,
24{
25 pub target_application: PathBuf,
28
29 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 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 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 pub async fn get_releases(&self) -> Result<Vec<Release>, Error> {
79 self.source.get_releases().await
80 }
81
82 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 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 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 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 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}