Skip to main content

rtb_update/
updater.rs

1//! The `Updater` — composed over `rtb-forge` providing release
2//! discovery and over `self-replace` for the atomic-swap step. See
3//! [`crate::flow`] for the step-by-step atomic-swap sequence.
4
5use std::marker::PhantomData;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use rtb_app::app::App;
10use rtb_forge::ReleaseProvider;
11
12use crate::asset;
13use crate::error::{Result, UpdateError};
14use crate::flow;
15use crate::options::{CheckOutcome, ProgressEvent, RunOptions, RunOutcome};
16use crate::verify;
17
18/// Typestate marker — the `app` field has not been set on the builder.
19pub struct NoApp;
20/// Typestate marker — the `app` field is set.
21pub struct HasApp;
22/// Typestate marker — the `provider` field has not been set on the builder.
23pub struct NoProvider;
24/// Typestate marker — the `provider` field is set.
25pub struct HasProvider;
26
27/// Self-updating client. Construct via [`Updater::builder`].
28pub struct Updater {
29    app: App,
30    provider: Arc<dyn ReleaseProvider>,
31    swap_fn: flow::SwapFn,
32    self_test_fn: flow::SelfTestFn,
33    /// When `Some`, overrides the default
34    /// `<project-cache-dir>/<tool>/update/<tag>/` staging path. Tests
35    /// supply a per-test tempdir so parallel test processes don't
36    /// race the same on-disk artefacts. Production tools typically
37    /// leave this `None`.
38    cache_dir: Option<PathBuf>,
39    include_framework_builtin: bool,
40}
41
42impl Updater {
43    /// Start the typestate builder. Both `app` and `provider` are
44    /// required at compile time — omitting either is a compile error.
45    #[must_use]
46    pub fn builder() -> UpdaterBuilder<NoApp, NoProvider> {
47        UpdaterBuilder {
48            app: None,
49            provider: None,
50            swap_fn: None,
51            self_test_fn: None,
52            cache_dir: None,
53            _markers: PhantomData,
54        }
55    }
56
57    /// The currently-installed version (from `rtb_app::App::version`).
58    #[must_use]
59    pub fn current_version(&self) -> &semver::Version {
60        &self.app.version.version
61    }
62
63    /// Fetch the latest release metadata and compare to the running
64    /// version. Cheap — no asset downloads.
65    ///
66    /// # Errors
67    ///
68    /// Propagates [`UpdateError::Provider`] from the VCS provider.
69    pub async fn check(&self) -> Result<CheckOutcome> {
70        let release = self.provider.latest_release().await?;
71        let Some(latest) = flow::parse_release_tag(&release.tag) else {
72            return Err(UpdateError::Pattern(format!(
73                "release tag `{}` is not a semver",
74                release.tag
75            )));
76        };
77        let current = self.current_version().clone();
78        Ok(match latest.cmp(&current) {
79            std::cmp::Ordering::Equal => CheckOutcome::UpToDate { current },
80            std::cmp::Ordering::Greater => CheckOutcome::Newer { current, latest, release },
81            std::cmp::Ordering::Less => CheckOutcome::Older { current, latest },
82        })
83    }
84
85    /// Full self-update flow: download, verify, optionally stage,
86    /// optionally swap.
87    ///
88    /// # Errors
89    ///
90    /// Any [`UpdateError`] variant — the flow is fail-fast and
91    /// preserves the pre-swap state on error.
92    pub async fn run(&self, options: RunOptions) -> Result<RunOutcome> {
93        self.preflight_required_fields()?;
94
95        emit(&options, ProgressEvent::Checking);
96
97        let release = match &options.target {
98            Some(version) => {
99                self.check_target_is_not_downgrade(version, options.force)?;
100                let tag = format!("v{version}");
101                self.provider.release_by_tag(&tag).await?
102            }
103            None => self.provider.latest_release().await?,
104        };
105
106        let latest = flow::parse_release_tag(&release.tag).ok_or_else(|| {
107            UpdateError::Pattern(format!("release tag `{}` is not a semver", release.tag))
108        })?;
109        let current = self.current_version().clone();
110
111        if latest == current && !options.force {
112            return Ok(RunOutcome {
113                from_version: current.clone(),
114                to_version: current,
115                bytes: 0,
116                swapped: false,
117                staged_at: None,
118            });
119        }
120
121        let expected_name = self.expected_asset_name(&release.tag);
122        let asset = asset::pick_asset(&release, &expected_name)?;
123        let signature = asset::pick_signature(&release, asset)
124            .ok_or_else(|| UpdateError::MissingSignature { asset: asset.name.clone() })?;
125
126        let cache_dir = self
127            .cache_dir
128            .clone()
129            .unwrap_or_else(|| flow::cache_dir_for(&self.app.metadata.name, &release.tag));
130        std::fs::create_dir_all(&cache_dir)?;
131        let staged_archive = cache_dir.join(&asset.name);
132
133        let bytes = flow::download_to_file(
134            &*self.provider,
135            asset,
136            &staged_archive,
137            options.progress.as_ref(),
138        )
139        .await?;
140
141        emit(&options, ProgressEvent::Verifying);
142
143        let sig_bytes = flow::fetch_small_asset(&*self.provider, signature).await?;
144        let archive_bytes = std::fs::read(&staged_archive)?;
145        verify::minisign(
146            &asset.name,
147            &archive_bytes,
148            &sig_bytes,
149            &self.app.metadata.update_public_keys,
150        )?;
151
152        if let Some(checksums_name) = self.app.metadata.update_checksums_asset {
153            let checksums_asset =
154                release.assets.iter().find(|a| a.name == checksums_name).ok_or_else(|| {
155                    UpdateError::BadChecksum { asset: checksums_name.to_string() }
156                })?;
157            let checksums_bytes = flow::fetch_small_asset(&*self.provider, checksums_asset).await?;
158            let checksums_text = String::from_utf8_lossy(&checksums_bytes);
159            verify::checksums(&asset.name, &archive_bytes, &checksums_text)?;
160        }
161
162        let bin_dir = cache_dir.join("bin");
163        let staged_binary =
164            flow::extract_binary(&staged_archive, &bin_dir, &self.app.metadata.name)?;
165
166        emit(&options, ProgressEvent::SelfTesting);
167
168        self.self_test_staged(&staged_binary, &release.tag)?;
169        flow::mark_executable(&staged_binary)?;
170
171        if options.dry_run {
172            let outcome = flow::dry_run_outcome(current, latest.clone(), bytes, staged_binary);
173            emit(&options, ProgressEvent::Done { version: latest });
174            return Ok(outcome);
175        }
176
177        emit(&options, ProgressEvent::Swapping);
178
179        (self.swap_fn)(&staged_binary).map_err(|e| UpdateError::SwapFailed(e.to_string()))?;
180
181        emit(&options, ProgressEvent::Done { version: latest.clone() });
182
183        Ok(flow::swap_outcome(current, latest, bytes))
184    }
185
186    /// Offline flow — verify + stage + swap from a pre-downloaded
187    /// asset + signature pair. Skips provider interaction entirely.
188    ///
189    /// # Errors
190    ///
191    /// Same shape as [`Self::run`] minus the [`UpdateError::Provider`]
192    /// variants.
193    pub async fn run_from_file(
194        &self,
195        asset_path: &Path,
196        signature_path: &Path,
197        options: RunOptions,
198    ) -> Result<RunOutcome> {
199        self.preflight_required_fields()?;
200
201        emit(&options, ProgressEvent::Verifying);
202
203        let asset_bytes = tokio::fs::read(asset_path).await?;
204        let sig_bytes = tokio::fs::read(signature_path).await?;
205        let asset_name =
206            asset_path.file_name().and_then(|n| n.to_str()).unwrap_or("asset").to_string();
207
208        // The signature's filename no longer selects a format — there
209        // is only one — so it is not passed through.
210        verify::minisign(
211            &asset_name,
212            &asset_bytes,
213            &sig_bytes,
214            &self.app.metadata.update_public_keys,
215        )?;
216
217        let current = self.current_version().clone();
218        let cache_dir = self
219            .cache_dir
220            .clone()
221            .unwrap_or_else(|| flow::cache_dir_for(&self.app.metadata.name, "offline"));
222        let bin_dir = cache_dir.join("bin");
223        let staged_binary = flow::extract_binary(asset_path, &bin_dir, &self.app.metadata.name)?;
224
225        emit(&options, ProgressEvent::SelfTesting);
226
227        let staged_version = self.self_test_version(&staged_binary)?;
228        flow::mark_executable(&staged_binary)?;
229
230        if options.dry_run {
231            return Ok(flow::dry_run_outcome(
232                current,
233                staged_version,
234                asset_bytes.len() as u64,
235                staged_binary,
236            ));
237        }
238
239        emit(&options, ProgressEvent::Swapping);
240
241        (self.swap_fn)(&staged_binary).map_err(|e| UpdateError::SwapFailed(e.to_string()))?;
242
243        emit(&options, ProgressEvent::Done { version: staged_version.clone() });
244
245        Ok(flow::swap_outcome(current, staged_version, asset_bytes.len() as u64))
246    }
247
248    // --- helpers ---
249
250    fn preflight_required_fields(&self) -> Result<()> {
251        if self.app.metadata.release_source.is_none() {
252            return Err(UpdateError::NoReleaseSource);
253        }
254        if self.app.metadata.update_public_keys.is_empty() {
255            return Err(UpdateError::NoPublicKey);
256        }
257        // `include_framework_builtin` exists to reserve surface; v0.1
258        // doesn't branch on it.
259        let _ = self.include_framework_builtin;
260        Ok(())
261    }
262
263    fn check_target_is_not_downgrade(&self, target: &semver::Version, force: bool) -> Result<()> {
264        if force {
265            return Ok(());
266        }
267        let current = self.current_version();
268        if target < current {
269            return Err(UpdateError::DowngradeRefused {
270                target: target.clone(),
271                current: current.clone(),
272            });
273        }
274        Ok(())
275    }
276
277    fn expected_asset_name(&self, tag: &str) -> String {
278        let pattern = self.app.metadata.update_asset_pattern.unwrap_or(asset::DEFAULT_PATTERN);
279        asset::render_pattern(pattern, &self.app.metadata.name, tag)
280    }
281
282    /// Invoke the staged binary and assert its `--version` output
283    /// mentions `expected_tag`. On mismatch or execution failure,
284    /// surfaces as [`UpdateError::SelfTestFailed`].
285    fn self_test_staged(&self, binary: &Path, expected_tag: &str) -> Result<()> {
286        let Ok(stdout) = (self.self_test_fn)(binary) else {
287            return Err(UpdateError::SelfTestFailed);
288        };
289        let tag_stripped = expected_tag.trim_start_matches(['v', 'V']);
290        if stdout.contains(expected_tag) || stdout.contains(tag_stripped) {
291            Ok(())
292        } else {
293            Err(UpdateError::SelfTestFailed)
294        }
295    }
296
297    /// Run the self-test but return the parsed version instead of
298    /// comparing against an expected tag. Used by `run_from_file`
299    /// where the tag is discovered from the binary itself.
300    fn self_test_version(&self, binary: &Path) -> Result<semver::Version> {
301        let Ok(stdout) = (self.self_test_fn)(binary) else {
302            return Err(UpdateError::SelfTestFailed);
303        };
304        // Extract the first semver-shaped token from the output.
305        for token in stdout.split_whitespace() {
306            let candidate = token.trim_start_matches(['v', 'V']);
307            if let Ok(v) = semver::Version::parse(candidate) {
308                return Ok(v);
309            }
310        }
311        Err(UpdateError::SelfTestFailed)
312    }
313}
314
315fn emit(options: &RunOptions, event: ProgressEvent) {
316    if let Some(sink) = &options.progress {
317        sink(event);
318    }
319}
320
321// ---------------------------------------------------------------------
322// UpdaterBuilder — typestate
323// ---------------------------------------------------------------------
324
325/// Typestate builder for [`Updater`].
326pub struct UpdaterBuilder<AppMarker, ProviderMarker> {
327    app: Option<App>,
328    provider: Option<Arc<dyn ReleaseProvider>>,
329    swap_fn: Option<flow::SwapFn>,
330    self_test_fn: Option<flow::SelfTestFn>,
331    cache_dir: Option<PathBuf>,
332    _markers: PhantomData<(AppMarker, ProviderMarker)>,
333}
334
335impl<P> UpdaterBuilder<NoApp, P> {
336    /// Set the tool's [`App`]. The updater clones it for its own use;
337    /// `App` is cheap to clone (every field is `Arc`-wrapped).
338    #[must_use]
339    pub fn app(self, app: &App) -> UpdaterBuilder<HasApp, P> {
340        UpdaterBuilder {
341            app: Some(app.clone()),
342            provider: self.provider,
343            swap_fn: self.swap_fn,
344            self_test_fn: self.self_test_fn,
345            cache_dir: self.cache_dir,
346            _markers: PhantomData,
347        }
348    }
349}
350
351impl<A> UpdaterBuilder<A, NoProvider> {
352    /// Set the release provider. Typically an `Arc<dyn ReleaseProvider>`
353    /// from [`rtb_forge::lookup`] resolved through the tool's
354    /// `ToolMetadata::release_source`.
355    #[must_use]
356    pub fn provider(self, provider: Arc<dyn ReleaseProvider>) -> UpdaterBuilder<A, HasProvider> {
357        UpdaterBuilder {
358            app: self.app,
359            provider: Some(provider),
360            swap_fn: self.swap_fn,
361            self_test_fn: self.self_test_fn,
362            cache_dir: self.cache_dir,
363            _markers: PhantomData,
364        }
365    }
366}
367
368impl<A, P> UpdaterBuilder<A, P> {
369    /// Override the swap step — tests inject a double so the real
370    /// `self-replace` is never invoked.
371    #[must_use]
372    pub fn swap_fn(mut self, swap_fn: flow::SwapFn) -> Self {
373        self.swap_fn = Some(swap_fn);
374        self
375    }
376
377    /// Override the self-test step — tests substitute a function that
378    /// doesn't fork a child.
379    #[must_use]
380    pub fn self_test_fn(mut self, self_test_fn: flow::SelfTestFn) -> Self {
381        self.self_test_fn = Some(self_test_fn);
382        self
383    }
384
385    /// Override the cache directory used to stage downloaded archives
386    /// and extracted binaries. Defaults to the project cache dir
387    /// (resolved via `directories::ProjectDirs`) joined with the
388    /// release tag.
389    ///
390    /// Tools call this when they want isolation per-invocation
391    /// (e.g. CI runners, tests with parallel processes) or to honour
392    /// a user-supplied `--cache-dir` flag.
393    #[must_use]
394    pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
395        self.cache_dir = Some(cache_dir.into());
396        self
397    }
398}
399
400impl UpdaterBuilder<HasApp, HasProvider> {
401    /// Finalise — only reachable when `app` and `provider` have both
402    /// been set. Any missing field is a compile error.
403    #[must_use]
404    pub fn build(self) -> Updater {
405        Updater {
406            app: self.app.expect("HasApp"),
407            provider: self.provider.expect("HasProvider"),
408            swap_fn: self.swap_fn.unwrap_or_else(flow::default_swap_fn),
409            self_test_fn: self.self_test_fn.unwrap_or_else(flow::default_self_test_fn),
410            cache_dir: self.cache_dir,
411            include_framework_builtin: true,
412        }
413    }
414}