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