Skip to main content

gpui_updater_core/
engine.rs

1//! The blocking update orchestrator that ties the pieces together.
2
3mod artifact;
4mod config;
5#[cfg(all(test, feature = "delta", any(target_os = "macos", target_os = "linux")))]
6mod delta_tests;
7#[cfg(feature = "delta")]
8mod hops;
9mod payload;
10#[cfg(all(
11    test,
12    feature = "delta",
13    any(target_os = "macos", target_os = "linux", target_os = "windows")
14))]
15mod test_server;
16#[cfg(test)]
17mod tests;
18#[cfg(all(test, feature = "delta", target_os = "windows"))]
19mod windows_delta_tests;
20
21use std::path::{Path, PathBuf};
22
23use semver::Version;
24
25pub use artifact::Artifact;
26use config::Integrity;
27pub use config::{
28    BundleVersion, CodeSigning, DEFAULT_MAX_DELTA_HOPS, DEFAULT_MAX_DOWNLOAD_SIZE, EngineConfig,
29    Verification, VersionBinding,
30};
31
32use crate::cancel::CancelToken;
33use crate::error::Result;
34use crate::http::{self, Fetch};
35use crate::install::{self, Installed, Ownership, OwnershipHints};
36use crate::release::Release;
37use crate::source::UpdateSource;
38use crate::staging;
39
40/// Blocking update orchestrator: check → download → verify → install.
41///
42/// All methods block; the GPUI integration runs them on a background executor.
43pub struct UpdateEngine<S: UpdateSource> {
44    source: S,
45    config: EngineConfig,
46}
47
48impl<S: UpdateSource> UpdateEngine<S> {
49    /// Build an engine from a source and configuration.
50    pub fn new(source: S, config: EngineConfig) -> Self {
51        Self { source, config }
52    }
53
54    /// The configured current version.
55    pub fn current_version(&self) -> &Version {
56        &self.config.current_version
57    }
58
59    /// Fetch the latest release, returning it only if it is newer than the
60    /// current version (otherwise `Ok(None)`).
61    ///
62    /// # Errors
63    /// Propagates source/network/parse errors. A newer release is rejected
64    /// instead of returned when the [`Verification`] policy cannot be met
65    /// ([`Error::VerificationRequired`](crate::Error::VerificationRequired)),
66    /// or when its artifact or signature would not be fetched over HTTPS
67    /// ([`Error::InsecureUrl`](crate::Error::InsecureUrl)).
68    pub fn check(&self) -> Result<Option<Release>> {
69        let latest = self.source.fetch_latest()?;
70        if latest.version > self.config.current_version {
71            self.config.precheck(&latest)?;
72            ensure_fetchable(&latest)?;
73            Ok(Some(latest))
74        } else {
75            Ok(None)
76        }
77    }
78
79    /// Download the update and verify it according to the configured
80    /// policies. Returns the [`Artifact`] to hand to [`install`](Self::install).
81    ///
82    /// Files are fetched over HTTPS only, into a fresh directory that only the
83    /// current user can access (or [`EngineConfig::download_dir`], where an
84    /// earlier download of the same artifact that still verifies is reused
85    /// instead of fetched again). A download
86    /// larger than the size the release publishes, or than
87    /// [`EngineConfig::max_download_size`], is cut off. The file is then
88    /// checked against its SHA-256 and minisign signature (see
89    /// [`Verification`] and [`VersionBinding`]), and on macOS the app inside
90    /// must declare the release version and carry the expected code signature
91    /// (see [`BundleVersion`] and [`CodeSigning`]).
92    ///
93    /// With the `delta` feature, published deltas are tried first: the
94    /// cheapest chain from the current version, if it is smaller than the
95    /// full artifact. On Windows this needs a bare `.exe` release: the
96    /// install root and the release asset must both be `.exe` files, so an
97    /// `.msi` release always takes the full download. Each delta is
98    /// downloaded, verified under the same policies, and applied to the files
99    /// it expects, which are checked against the hashes recorded in the
100    /// delta. The result is a directory holding the rebuilt bundle or
101    /// executable, which goes through the same payload checks as the full
102    /// artifact. If anything in that path fails — no usable delta, a modified
103    /// installation, a bad checksum or signature, a network error — the full
104    /// `release.asset` is downloaded instead, so deltas can never make an
105    /// update fail.
106    ///
107    /// `progress` is called as `(downloaded_bytes, total_bytes)`. After a failed
108    /// delta attempt it restarts from zero for the full download.
109    ///
110    /// To stop a download from another thread, use
111    /// [`download_cancellable`](Self::download_cancellable).
112    ///
113    /// # Errors
114    /// Returns an error on download failure, if verification fails, if the
115    /// policy requires a check the release cannot satisfy
116    /// ([`Error::VerificationRequired`](crate::Error::VerificationRequired)),
117    /// or, before anything is downloaded, if the update could not be
118    /// installed where the app is ([`check_location`](Self::check_location)).
119    pub fn download(
120        &self,
121        release: &Release,
122        progress: impl FnMut(u64, Option<u64>),
123    ) -> Result<Artifact> {
124        self.download_cancellable(release, &CancelToken::new(), progress)
125    }
126
127    /// [`download`](Self::download), stopping with
128    /// [`Error::Cancelled`](crate::Error::Cancelled) once `cancel` is
129    /// cancelled. See [`CancelToken`] for when a cancel takes effect. A
130    /// cancelled download removes its partial file; a delta attempt that is
131    /// cancelled does not fall back to the full download.
132    ///
133    /// # Errors
134    /// As [`download`](Self::download), plus
135    /// [`Error::Cancelled`](crate::Error::Cancelled).
136    pub fn download_cancellable(
137        &self,
138        release: &Release,
139        cancel: &CancelToken,
140        mut progress: impl FnMut(u64, Option<u64>),
141    ) -> Result<Artifact> {
142        cancel.check()?;
143        self.check_location(release)?;
144        let dir = self.download_dir()?;
145        // The name comes from release metadata; keep the file inside `dir`.
146        let artifact = dir.join(staging::safe_file_name(&release.asset.name, "update"));
147        if self.config.download_dir.is_some() {
148            if let Some(len) = self.reusable(&artifact, release) {
149                tracing::info!(path = %artifact.display(), "reusing the verified earlier download");
150                progress(len, Some(len));
151                return Ok(Artifact::verified(artifact));
152            }
153        }
154        #[cfg(feature = "delta")]
155        if let Some(rebuilt) = self.try_deltas(release, &dir, cancel, &mut progress) {
156            match self.check_payload(&rebuilt, release) {
157                Ok(()) => return Ok(Artifact::verified(rebuilt)),
158                Err(e) => {
159                    tracing::warn!(error = %e, "rebuilt update rejected; falling back to the full download");
160                    let _ = std::fs::remove_dir_all(&rebuilt);
161                }
162            }
163        }
164        // A delta attempt that was cancelled must not turn into a full download.
165        cancel.check()?;
166        let fetch = Fetch::new(&release.asset.url)
167            .expect_len(release.asset.size)
168            .max_len(self.config.max_download_size)
169            .cancel(cancel)
170            .http(&self.config.http);
171        http::download(&fetch, &artifact, &mut progress)?;
172        cancel.check()?;
173        self.config
174            .verify_file(&artifact, Integrity::of_release(release), &release.version)?;
175        self.check_payload(&artifact, release)?;
176        Ok(Artifact::verified(artifact))
177    }
178
179    /// The size of `path`, if an earlier download left this release's
180    /// artifact there: a regular file of the published size that passes a
181    /// published checksum or signature and the payload check. A file that
182    /// passes only because nothing was published to check it against is not
183    /// reused.
184    fn reusable(&self, path: &Path, release: &Release) -> Option<u64> {
185        let meta = std::fs::symlink_metadata(path).ok()?;
186        let size = release.asset.size;
187        if !meta.is_file() || (size > 0 && meta.len() != size) {
188            return None;
189        }
190        match self
191            .config
192            .verify_file(path, Integrity::of_release(release), &release.version)
193        {
194            Ok(true) => {}
195            Ok(false) => return None,
196            Err(e) => {
197                tracing::debug!(error = %e, "not reusing the earlier download");
198                return None;
199            }
200        }
201        self.check_payload(path, release).ok()?;
202        Some(meta.len())
203    }
204
205    /// Check the app inside a verified artifact before it is offered for
206    /// install (macOS).
207    fn check_payload(&self, artifact: &Path, release: &Release) -> Result<()> {
208        if self.config.verification.skips_all() {
209            return Ok(());
210        }
211        let installed = self.install_root()?;
212        payload::check(
213            artifact,
214            &payload::Expectations {
215                version: &release.version,
216                installed: &installed,
217                bundle_version: self.config.bundle_version,
218                code_signing: &self.config.code_signing,
219            },
220        )
221    }
222
223    fn download_dir(&self) -> Result<PathBuf> {
224        match &self.config.download_dir {
225            Some(dir) => {
226                staging::ensure_private_dir(dir)?;
227                Ok(dir.clone())
228            }
229            None => Ok(staging::private_temp_dir("gpui-updater")?),
230        }
231    }
232
233    /// Check that `release` could be installed where the app is, without
234    /// downloading anything: see [`install::check_location`].
235    /// [`download`](Self::download) runs this first; call it yourself to tell
236    /// the user before offering the download.
237    ///
238    /// # Errors
239    /// [`Error::Location`](crate::Error::Location) when the app runs from a
240    /// read-only place, such as a disk image or an App Translocation copy, and
241    /// [`Error::Permission`](crate::Error::Permission) when the user may not
242    /// write next to it.
243    pub fn check_location(&self, release: &Release) -> Result<()> {
244        install::check_location(&self.install_root()?, &release.asset.name)
245    }
246
247    fn install_root(&self) -> Result<PathBuf> {
248        match &self.config.install_root {
249            Some(root) => Ok(root.clone()),
250            None => install::current_install_root(),
251        }
252    }
253
254    /// Install a downloaded artifact over the configured install root.
255    ///
256    /// Only an [`Artifact`] returned by [`download`](Self::download) is
257    /// accepted, so nothing reaches the installer without passing the
258    /// engine's checks.
259    ///
260    /// # Errors
261    /// Propagates install failures.
262    pub fn install(&self, artifact: &Artifact) -> Result<Installed> {
263        install::install_with(
264            artifact.path(),
265            &self.install_root()?,
266            &self.config.install_options,
267        )
268    }
269
270    /// Remove what an earlier install left beside the install root because
271    /// the old version was still running, such as the previous `.exe` on
272    /// Windows: see [`install::remove_leftovers`]. Call it at startup.
273    pub fn remove_leftovers(&self) {
274        if let Ok(root) = self.install_root() {
275            install::remove_leftovers(&root);
276        }
277    }
278
279    /// Who keeps the installed app up to date: the app itself, a package
280    /// manager, or the packager. See [`install::ownership`]. When it is not
281    /// [`Ownership::SelfUpdate`], tell the user how to update instead of
282    /// offering to. When the install root is unknown, the app is taken to
283    /// update itself.
284    #[must_use]
285    pub fn ownership(&self, hints: &OwnershipHints) -> Ownership {
286        self.install_root().map_or(Ownership::SelfUpdate, |root| {
287            install::ownership(&root, hints)
288        })
289    }
290
291    /// Run the full pipeline. Returns `Ok(None)` when already up to date.
292    ///
293    /// # Errors
294    /// Propagates any check/download/verify/install error.
295    pub fn update_now(&self, progress: impl FnMut(u64, Option<u64>)) -> Result<Option<Installed>> {
296        let Some(release) = self.check()? else {
297            return Ok(None);
298        };
299        let artifact = self.download(&release, progress)?;
300        Ok(Some(self.install(&artifact)?))
301    }
302}
303
304/// Fail when the release's artifact or detached signature would not be
305/// fetched over HTTPS, so such a release is never offered as available.
306fn ensure_fetchable(release: &Release) -> Result<()> {
307    http::ensure_secure(&release.asset.url)?;
308    match release
309        .signature_url
310        .as_deref()
311        .map(str::trim)
312        .filter(|url| !url.is_empty())
313    {
314        Some(url) => http::ensure_secure(url),
315        None => Ok(()),
316    }
317}