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
13pub struct UpdateManager<S = GitHubSource>
26where
27 S: Source,
28{
29 pub target_application: PathBuf,
32
33 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 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 pub fn with_target_application(mut self, target_application: PathBuf) -> Self {
58 self.target_application = target_application;
59 self
60 }
61
62 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 #[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 #[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 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 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 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 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 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 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 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}