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::ed25519(
146            &asset.name,
147            &signature.name,
148            &archive_bytes,
149            &sig_bytes,
150            &self.app.metadata.update_public_keys,
151        )?;
152
153        if let Some(checksums_name) = self.app.metadata.update_checksums_asset {
154            let checksums_asset =
155                release.assets.iter().find(|a| a.name == checksums_name).ok_or_else(|| {
156                    UpdateError::BadChecksum { asset: checksums_name.to_string() }
157                })?;
158            let checksums_bytes = flow::fetch_small_asset(&*self.provider, checksums_asset).await?;
159            let checksums_text = String::from_utf8_lossy(&checksums_bytes);
160            verify::checksums(&asset.name, &archive_bytes, &checksums_text)?;
161        }
162
163        let bin_dir = cache_dir.join("bin");
164        let staged_binary =
165            flow::extract_binary(&staged_archive, &bin_dir, &self.app.metadata.name)?;
166
167        emit(&options, ProgressEvent::SelfTesting);
168
169        self.self_test_staged(&staged_binary, &release.tag)?;
170        flow::mark_executable(&staged_binary)?;
171
172        if options.dry_run {
173            let outcome = flow::dry_run_outcome(current, latest.clone(), bytes, staged_binary);
174            emit(&options, ProgressEvent::Done { version: latest });
175            return Ok(outcome);
176        }
177
178        emit(&options, ProgressEvent::Swapping);
179
180        (self.swap_fn)(&staged_binary).map_err(|e| UpdateError::SwapFailed(e.to_string()))?;
181
182        emit(&options, ProgressEvent::Done { version: latest.clone() });
183
184        Ok(flow::swap_outcome(current, latest, bytes))
185    }
186
187    /// Offline flow — verify + stage + swap from a pre-downloaded
188    /// asset + signature pair. Skips provider interaction entirely.
189    ///
190    /// # Errors
191    ///
192    /// Same shape as [`Self::run`] minus the [`UpdateError::Provider`]
193    /// variants.
194    pub async fn run_from_file(
195        &self,
196        asset_path: &Path,
197        signature_path: &Path,
198        options: RunOptions,
199    ) -> Result<RunOutcome> {
200        self.preflight_required_fields()?;
201
202        emit(&options, ProgressEvent::Verifying);
203
204        let asset_bytes = tokio::fs::read(asset_path).await?;
205        let sig_bytes = tokio::fs::read(signature_path).await?;
206        let asset_name =
207            asset_path.file_name().and_then(|n| n.to_str()).unwrap_or("asset").to_string();
208        let sig_name =
209            signature_path.file_name().and_then(|n| n.to_str()).unwrap_or("asset.sig").to_string();
210
211        verify::ed25519(
212            &asset_name,
213            &sig_name,
214            &asset_bytes,
215            &sig_bytes,
216            &self.app.metadata.update_public_keys,
217        )?;
218
219        let current = self.current_version().clone();
220        let cache_dir = self
221            .cache_dir
222            .clone()
223            .unwrap_or_else(|| flow::cache_dir_for(&self.app.metadata.name, "offline"));
224        let bin_dir = cache_dir.join("bin");
225        let staged_binary = flow::extract_binary(asset_path, &bin_dir, &self.app.metadata.name)?;
226
227        emit(&options, ProgressEvent::SelfTesting);
228
229        let staged_version = self.self_test_version(&staged_binary)?;
230        flow::mark_executable(&staged_binary)?;
231
232        if options.dry_run {
233            return Ok(flow::dry_run_outcome(
234                current,
235                staged_version,
236                asset_bytes.len() as u64,
237                staged_binary,
238            ));
239        }
240
241        emit(&options, ProgressEvent::Swapping);
242
243        (self.swap_fn)(&staged_binary).map_err(|e| UpdateError::SwapFailed(e.to_string()))?;
244
245        emit(&options, ProgressEvent::Done { version: staged_version.clone() });
246
247        Ok(flow::swap_outcome(current, staged_version, asset_bytes.len() as u64))
248    }
249
250    // --- helpers ---
251
252    fn preflight_required_fields(&self) -> Result<()> {
253        if self.app.metadata.release_source.is_none() {
254            return Err(UpdateError::NoReleaseSource);
255        }
256        if self.app.metadata.update_public_keys.is_empty() {
257            return Err(UpdateError::NoPublicKey);
258        }
259        // `include_framework_builtin` exists to reserve surface; v0.1
260        // doesn't branch on it.
261        let _ = self.include_framework_builtin;
262        Ok(())
263    }
264
265    fn check_target_is_not_downgrade(&self, target: &semver::Version, force: bool) -> Result<()> {
266        if force {
267            return Ok(());
268        }
269        let current = self.current_version();
270        if target < current {
271            return Err(UpdateError::DowngradeRefused {
272                target: target.clone(),
273                current: current.clone(),
274            });
275        }
276        Ok(())
277    }
278
279    fn expected_asset_name(&self, tag: &str) -> String {
280        let pattern = self.app.metadata.update_asset_pattern.unwrap_or(asset::DEFAULT_PATTERN);
281        asset::render_pattern(pattern, &self.app.metadata.name, tag)
282    }
283
284    /// Invoke the staged binary and assert its `--version` output
285    /// mentions `expected_tag`. On mismatch or execution failure,
286    /// surfaces as [`UpdateError::SelfTestFailed`].
287    fn self_test_staged(&self, binary: &Path, expected_tag: &str) -> Result<()> {
288        let Ok(stdout) = (self.self_test_fn)(binary) else {
289            return Err(UpdateError::SelfTestFailed);
290        };
291        let tag_stripped = expected_tag.trim_start_matches(['v', 'V']);
292        if stdout.contains(expected_tag) || stdout.contains(tag_stripped) {
293            Ok(())
294        } else {
295            Err(UpdateError::SelfTestFailed)
296        }
297    }
298
299    /// Run the self-test but return the parsed version instead of
300    /// comparing against an expected tag. Used by `run_from_file`
301    /// where the tag is discovered from the binary itself.
302    fn self_test_version(&self, binary: &Path) -> Result<semver::Version> {
303        let Ok(stdout) = (self.self_test_fn)(binary) else {
304            return Err(UpdateError::SelfTestFailed);
305        };
306        // Extract the first semver-shaped token from the output.
307        for token in stdout.split_whitespace() {
308            let candidate = token.trim_start_matches(['v', 'V']);
309            if let Ok(v) = semver::Version::parse(candidate) {
310                return Ok(v);
311            }
312        }
313        Err(UpdateError::SelfTestFailed)
314    }
315}
316
317fn emit(options: &RunOptions, event: ProgressEvent) {
318    if let Some(sink) = &options.progress {
319        sink(event);
320    }
321}
322
323// ---------------------------------------------------------------------
324// UpdaterBuilder — typestate
325// ---------------------------------------------------------------------
326
327/// Typestate builder for [`Updater`].
328pub struct UpdaterBuilder<AppMarker, ProviderMarker> {
329    app: Option<App>,
330    provider: Option<Arc<dyn ReleaseProvider>>,
331    swap_fn: Option<flow::SwapFn>,
332    self_test_fn: Option<flow::SelfTestFn>,
333    cache_dir: Option<PathBuf>,
334    _markers: PhantomData<(AppMarker, ProviderMarker)>,
335}
336
337impl<P> UpdaterBuilder<NoApp, P> {
338    /// Set the tool's [`App`]. The updater clones it for its own use;
339    /// `App` is cheap to clone (every field is `Arc`-wrapped).
340    #[must_use]
341    pub fn app(self, app: &App) -> UpdaterBuilder<HasApp, P> {
342        UpdaterBuilder {
343            app: Some(app.clone()),
344            provider: self.provider,
345            swap_fn: self.swap_fn,
346            self_test_fn: self.self_test_fn,
347            cache_dir: self.cache_dir,
348            _markers: PhantomData,
349        }
350    }
351}
352
353impl<A> UpdaterBuilder<A, NoProvider> {
354    /// Set the release provider. Typically an `Arc<dyn ReleaseProvider>`
355    /// from [`rtb_forge::lookup`] resolved through the tool's
356    /// `ToolMetadata::release_source`.
357    #[must_use]
358    pub fn provider(self, provider: Arc<dyn ReleaseProvider>) -> UpdaterBuilder<A, HasProvider> {
359        UpdaterBuilder {
360            app: self.app,
361            provider: Some(provider),
362            swap_fn: self.swap_fn,
363            self_test_fn: self.self_test_fn,
364            cache_dir: self.cache_dir,
365            _markers: PhantomData,
366        }
367    }
368}
369
370impl<A, P> UpdaterBuilder<A, P> {
371    /// Override the swap step — tests inject a double so the real
372    /// `self-replace` is never invoked.
373    #[must_use]
374    pub fn swap_fn(mut self, swap_fn: flow::SwapFn) -> Self {
375        self.swap_fn = Some(swap_fn);
376        self
377    }
378
379    /// Override the self-test step — tests substitute a function that
380    /// doesn't fork a child.
381    #[must_use]
382    pub fn self_test_fn(mut self, self_test_fn: flow::SelfTestFn) -> Self {
383        self.self_test_fn = Some(self_test_fn);
384        self
385    }
386
387    /// Override the cache directory used to stage downloaded archives
388    /// and extracted binaries. Defaults to the project cache dir
389    /// (resolved via `directories::ProjectDirs`) joined with the
390    /// release tag.
391    ///
392    /// Tools call this when they want isolation per-invocation
393    /// (e.g. CI runners, tests with parallel processes) or to honour
394    /// a user-supplied `--cache-dir` flag.
395    #[must_use]
396    pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
397        self.cache_dir = Some(cache_dir.into());
398        self
399    }
400}
401
402impl UpdaterBuilder<HasApp, HasProvider> {
403    /// Finalise — only reachable when `app` and `provider` have both
404    /// been set. Any missing field is a compile error.
405    #[must_use]
406    pub fn build(self) -> Updater {
407        Updater {
408            app: self.app.expect("HasApp"),
409            provider: self.provider.expect("HasProvider"),
410            swap_fn: self.swap_fn.unwrap_or_else(flow::default_swap_fn),
411            self_test_fn: self.self_test_fn.unwrap_or_else(flow::default_self_test_fn),
412            cache_dir: self.cache_dir,
413            include_framework_builtin: true,
414        }
415    }
416}