gpui_updater_core/engine/config.rs
1//! Engine configuration and the verification of one downloaded file.
2
3use std::path::{Path, PathBuf};
4
5use semver::Version;
6
7use crate::error::{Error, Result};
8use crate::http::{self, HttpOptions, Metadata};
9use crate::install::InstallOptions;
10use crate::release::{DeltaAsset, Release};
11use crate::verify;
12
13/// Longest chain of deltas the engine downloads by default.
14pub const DEFAULT_MAX_DELTA_HOPS: usize = 4;
15
16/// Largest download the engine accepts by default: 4 GiB, twice what GitHub
17/// Releases allows for a single asset.
18pub const DEFAULT_MAX_DOWNLOAD_SIZE: u64 = 4 * 1024 * 1024 * 1024;
19
20/// How strictly [`UpdateEngine`](crate::UpdateEngine) enforces integrity and
21/// authenticity checks on a release.
22///
23/// A "signature" is either a minisign signature (checked against
24/// [`EngineConfig::minisign_public_key`]) or a Sparkle-style Ed25519
25/// signature (checked against [`EngineConfig::ed25519_public_key`]). Every
26/// signature the release publishes *and* a configured key can check is
27/// verified, and any mismatch fails the download. The same applies to deltas.
28///
29/// The default, [`BestEffort`](Verification::BestEffort), verifies whatever the
30/// release provides and *skips* a check when its input is absent — so an
31/// unsigned release installs with only a checksum, or with nothing. The
32/// `Signature`/`Strict` modes instead fail closed: a release that cannot be
33/// verified is rejected at [`check`](crate::UpdateEngine::check) time (before
34/// it is surfaced as available) and again at
35/// [`download`](crate::UpdateEngine::download) time.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub enum Verification {
39 /// Verify whatever the release and config provide; skip a check when its
40 /// input is missing (no checksum, no signature, or no public key). The
41 /// backwards-compatible default — note it fails *open*.
42 #[default]
43 BestEffort,
44 /// Skip all integrity, authenticity, and payload checks. For tests and
45 /// local development only.
46 Off,
47 /// Require a matching SHA-256. Fails closed when the release publishes no
48 /// checksum. A signature is still verified when one is available.
49 Checksum,
50 /// Require a valid signature: the release must publish a minisign or
51 /// Ed25519 signature for which a public key is configured. A checksum is
52 /// still verified when available, but is not itself required.
53 Signature,
54 /// Require both a valid signature and a matching SHA-256.
55 Strict,
56}
57
58impl Verification {
59 pub(super) fn skips_all(self) -> bool {
60 matches!(self, Self::Off)
61 }
62
63 fn requires_signature(self) -> bool {
64 matches!(self, Self::Signature | Self::Strict)
65 }
66
67 fn requires_checksum(self) -> bool {
68 matches!(self, Self::Checksum | Self::Strict)
69 }
70}
71
72/// Whether a signature must name the version it was made for.
73///
74/// minisign signs a *trusted comment* along with the file. When it carries a
75/// `version:<semver>` token (see [`verify::signed_version`]), the engine
76/// checks it against the version the file is supposed to be: the release
77/// version for a full artifact, the target version for a delta. That stops an
78/// older, validly signed artifact from being served as a newer one.
79///
80/// Sparkle-style Ed25519 signatures sign the file alone and cannot name a
81/// version; on macOS, [`BundleVersion`] covers them instead.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83#[non_exhaustive]
84pub enum VersionBinding {
85 /// Check the `version:` token when a verified signature carries one.
86 #[default]
87 IfPresent,
88 /// Reject a verified signature without a `version:` token. Use once every
89 /// artifact you publish is signed with one.
90 Required,
91}
92
93/// Which code signature an update must carry: the app bundle's on macOS, the
94/// `.exe` or `.msi` Authenticode signature on Windows. Linux has no platform
95/// code signature, so nothing is checked there.
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97#[non_exhaustive]
98pub enum CodeSigning {
99 /// Require the update to be signed like the installed app, when the
100 /// installed app is signed. On macOS, a team-signed app needs an update
101 /// that passes `codesign --verify --deep --strict` and is signed by the
102 /// same team; unsigned and ad-hoc signed apps are not checked. On Windows,
103 /// an app with a valid Authenticode signature needs an update with a valid
104 /// signature whose certificate has the same common name (CN); unsigned
105 /// apps are not checked.
106 #[default]
107 MatchInstalled,
108 /// Require a valid signature by this Team ID, whatever the installed app
109 /// carries (macOS; ignored on Windows).
110 TeamId(String),
111 /// Require a valid Authenticode signature whose certificate's common name
112 /// (CN) is this publisher name, such as `Example Corp`, whatever the
113 /// installed app carries (Windows; ignored on macOS).
114 Publisher(String),
115 /// Do not check code signatures.
116 Off,
117}
118
119/// Whether the app bundle of an update must declare the release's version
120/// (macOS).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122#[non_exhaustive]
123pub enum BundleVersion {
124 /// Require `CFBundleShortVersionString` to equal the release version
125 /// (missing components count as zero, so `1.2` matches `1.2.0`).
126 #[default]
127 MustMatch,
128 /// Do not compare bundle versions.
129 Ignore,
130}
131
132/// Configuration for an [`UpdateEngine`](crate::UpdateEngine).
133#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct EngineConfig {
136 /// The currently running version, compared against the source's latest.
137 pub current_version: Version,
138 /// Base64 minisign public keys. A signature made by any of them is
139 /// accepted, so a new key can ship next to the old one before releases
140 /// switch to it.
141 pub minisign_public_keys: Vec<String>,
142 /// Base64 Ed25519 public key in Sparkle's format (the `SUPublicEDKey`
143 /// value printed by Sparkle's `generate_keys`). When set and the release
144 /// publishes an [`ed25519_signature`](Release::ed25519_signature), the
145 /// download is signature-checked.
146 pub ed25519_public_key: Option<String>,
147 /// How strictly downloads are verified. See [`Verification`].
148 pub verification: Verification,
149 /// Whether signatures must name the version they were made for.
150 pub version_binding: VersionBinding,
151 /// Which code signature the update must carry (macOS).
152 pub code_signing: CodeSigning,
153 /// Whether the update's bundle version must equal the release (macOS).
154 pub bundle_version: BundleVersion,
155 /// Where to install. Defaults to [`crate::install::current_install_root`].
156 pub install_root: Option<PathBuf>,
157 /// Directory for downloads. Defaults to a fresh temp directory that only
158 /// the current user can access. A directory set here should not be
159 /// writable by other users either; the engine creates it (and missing
160 /// parents) with owner-only permissions if it does not exist.
161 pub download_dir: Option<PathBuf>,
162 /// Largest file, in bytes, the engine downloads: a release publishing a
163 /// larger size is refused before anything is fetched, and a body growing
164 /// past this is cut off even when no size is published. Defaults to
165 /// [`DEFAULT_MAX_DOWNLOAD_SIZE`].
166 pub max_download_size: u64,
167 /// Whether to try published binary deltas before the full artifact.
168 /// Defaults to `true`; only takes effect with the `delta` feature on macOS
169 /// and Linux.
170 pub deltas: bool,
171 /// Longest chain of deltas to download when none starts at the installed
172 /// version. Defaults to [`DEFAULT_MAX_DELTA_HOPS`].
173 pub max_delta_hops: usize,
174 /// Extra headers and the proxy for artifacts, deltas, and their
175 /// checksum and signature files. Sources have their own.
176 pub http: HttpOptions,
177 /// How [`UpdateEngine::install`](crate::UpdateEngine::install) installs
178 /// the update, such as the Windows Installer UI for an `.msi`.
179 pub install_options: InstallOptions,
180}
181
182impl EngineConfig {
183 /// Start a config for the given running version.
184 pub fn new(current_version: Version) -> Self {
185 Self {
186 current_version,
187 minisign_public_keys: Vec::new(),
188 ed25519_public_key: None,
189 verification: Verification::default(),
190 version_binding: VersionBinding::default(),
191 code_signing: CodeSigning::default(),
192 bundle_version: BundleVersion::default(),
193 install_root: None,
194 download_dir: None,
195 max_download_size: DEFAULT_MAX_DOWNLOAD_SIZE,
196 deltas: true,
197 max_delta_hops: DEFAULT_MAX_DELTA_HOPS,
198 http: HttpOptions::new(),
199 install_options: InstallOptions::new(),
200 }
201 }
202
203 /// Trust a base64 minisign public key. Call again to trust several, for
204 /// example while rotating keys.
205 #[must_use]
206 pub fn minisign_public_key(mut self, key: impl Into<String>) -> Self {
207 self.minisign_public_keys.push(key.into());
208 self
209 }
210
211 /// Set the base64 Ed25519 public key (Sparkle's `SUPublicEDKey`) used to
212 /// verify `sparkle:edSignature` signatures.
213 #[must_use]
214 pub fn ed25519_public_key(mut self, key: impl Into<String>) -> Self {
215 self.ed25519_public_key = Some(key.into());
216 self
217 }
218
219 /// Set how strictly downloads are verified. Defaults to
220 /// [`Verification::BestEffort`].
221 #[must_use]
222 pub fn verification(mut self, verification: Verification) -> Self {
223 self.verification = verification;
224 self
225 }
226
227 /// Set whether signatures must name their version. Defaults to
228 /// [`VersionBinding::IfPresent`].
229 #[must_use]
230 pub fn version_binding(mut self, binding: VersionBinding) -> Self {
231 self.version_binding = binding;
232 self
233 }
234
235 /// Set which code signature updates must carry. Defaults to
236 /// [`CodeSigning::MatchInstalled`].
237 #[must_use]
238 pub fn code_signing(mut self, code_signing: CodeSigning) -> Self {
239 self.code_signing = code_signing;
240 self
241 }
242
243 /// Set how updates are installed. Defaults to
244 /// [`InstallOptions::new`].
245 #[must_use]
246 pub fn install_options(mut self, options: InstallOptions) -> Self {
247 self.install_options = options;
248 self
249 }
250
251 /// Set whether bundle versions are compared. Defaults to
252 /// [`BundleVersion::MustMatch`].
253 #[must_use]
254 pub fn bundle_version(mut self, bundle_version: BundleVersion) -> Self {
255 self.bundle_version = bundle_version;
256 self
257 }
258
259 /// Override the install location (defaults to the running app/bundle).
260 #[must_use]
261 pub fn install_root(mut self, path: impl Into<PathBuf>) -> Self {
262 self.install_root = Some(path.into());
263 self
264 }
265
266 /// Override the download directory (defaults to a fresh temp directory
267 /// for each download).
268 ///
269 /// A configured directory persists between downloads, so an artifact
270 /// that an earlier download left there is reused instead of downloaded
271 /// again — but only if it has the published size and passes a published
272 /// checksum or signature and the payload check. Without a checksum or
273 /// signature to check it against, the file is downloaded again.
274 #[must_use]
275 pub fn download_dir(mut self, path: impl Into<PathBuf>) -> Self {
276 self.download_dir = Some(path.into());
277 self
278 }
279
280 /// Set the largest file, in bytes, the engine downloads. Defaults to
281 /// [`DEFAULT_MAX_DOWNLOAD_SIZE`].
282 #[must_use]
283 pub fn max_download_size(mut self, bytes: u64) -> Self {
284 self.max_download_size = bytes;
285 self
286 }
287
288 /// Enable or disable trying binary deltas before the full artifact.
289 /// Defaults to `true`. Has no effect without the `delta` feature.
290 #[must_use]
291 pub fn deltas(mut self, enabled: bool) -> Self {
292 self.deltas = enabled;
293 self
294 }
295
296 /// Set the longest chain of deltas to download. `1` only uses a delta
297 /// from the installed version itself.
298 #[must_use]
299 pub fn max_delta_hops(mut self, hops: usize) -> Self {
300 self.max_delta_hops = hops;
301 self
302 }
303
304 /// Download with `http`'s extra headers and proxy: the artifact, deltas,
305 /// and their checksum and signature files. Metadata requests use the
306 /// options set on the source.
307 #[must_use]
308 pub fn http(mut self, http: HttpOptions) -> Self {
309 self.http = http;
310 self
311 }
312
313 /// Fail closed when the policy requires a check the release *metadata*
314 /// cannot satisfy (no public key, no advertised signature, no checksum).
315 ///
316 /// Run at check time so an obviously unverifiable release is not surfaced
317 /// as available, and again before a download. This validates presence
318 /// only: a release advertising a `signature_url` passes here even if that
319 /// URL later fails to fetch or yields an invalid signature — those are
320 /// caught when the download is verified.
321 pub(super) fn precheck(&self, release: &Release) -> Result<()> {
322 self.precheck_integrity(Integrity::of_release(release))
323 }
324
325 /// [`precheck`](Self::precheck) for any downloadable file's metadata.
326 pub(super) fn precheck_integrity(&self, file: Integrity<'_>) -> Result<()> {
327 let what = file.what;
328 if self.verification.requires_signature() && !self.can_verify_signature_of(file) {
329 if self.minisign_public_keys.is_empty() && self.ed25519_public_key.is_none() {
330 return Err(Error::VerificationRequired(
331 "policy requires a signature, but no minisign or Ed25519 public key is configured"
332 .into(),
333 ));
334 }
335 return Err(Error::VerificationRequired(format!(
336 "policy requires a signature, but the {what} publishes none that a configured key can verify"
337 )));
338 }
339 if self.verification.requires_checksum() && !file.has_checksum() {
340 return Err(Error::VerificationRequired(format!(
341 "policy requires a SHA-256 checksum, but the {what} publishes none"
342 )));
343 }
344 Ok(())
345 }
346
347 /// Whether at least one signature the file advertises has a matching
348 /// configured key.
349 fn can_verify_signature_of(&self, file: Integrity<'_>) -> bool {
350 let minisign = !self.minisign_public_keys.is_empty() && file.has_minisign();
351 let ed25519 =
352 self.ed25519_public_key.is_some() && non_empty(file.ed25519_signature).is_some();
353 minisign || ed25519
354 }
355
356 /// Verify a downloaded file under the configured policy. `version` is
357 /// the version the file must be signed for. Fails closed first (so a
358 /// policy-required-but-missing input is rejected before the file is read),
359 /// then runs whatever checks the metadata does provide. Returns whether a
360 /// published checksum or signature was actually checked.
361 pub(super) fn verify_file(
362 &self,
363 path: &Path,
364 file: Integrity<'_>,
365 version: &Version,
366 ) -> Result<bool> {
367 let policy = self.verification;
368 if policy.skips_all() {
369 return Ok(false);
370 }
371 // `download` is public and may be called without `check`, so re-run the
372 // check-time gate here rather than trusting the caller went through it.
373 self.precheck_integrity(file)?;
374
375 let mut verified_checksum = false;
376 match resolve_checksum(file, &self.http)? {
377 Some(expected) => {
378 verify::verify_sha256(path, &expected)?;
379 verified_checksum = true;
380 }
381 None if policy.requires_checksum() => {
382 return Err(Error::VerificationRequired(format!(
383 "policy requires a SHA-256 checksum, but the published checksums list no `{}`",
384 file.name
385 )));
386 }
387 None => {}
388 }
389 let mut verified_signature = false;
390 if let (Some(key), Some(signature)) =
391 (&self.ed25519_public_key, non_empty(file.ed25519_signature))
392 {
393 verify::verify_ed25519(path, key, signature)?;
394 verified_signature = true;
395 }
396 if !self.minisign_public_keys.is_empty() {
397 if let Some(signature) = resolve_minisign(file, &self.http)? {
398 let keys: Vec<&str> = self
399 .minisign_public_keys
400 .iter()
401 .map(String::as_str)
402 .collect();
403 let comment = verify::verify_minisign_file(path, &keys, &signature)?;
404 self.check_signed_version(&comment, version)?;
405 verified_signature = true;
406 }
407 }
408 // `precheck` may have accepted a non-empty `signature_url` whose fetched
409 // body turned out blank. Fail closed instead of relying on a verifier to
410 // reject an empty signature string.
411 if policy.requires_signature() && !verified_signature {
412 return Err(Error::VerificationRequired(
413 "policy requires a signature, but the published signature is empty".into(),
414 ));
415 }
416 Ok(verified_checksum || verified_signature)
417 }
418
419 fn check_signed_version(&self, trusted_comment: &str, expected: &Version) -> Result<()> {
420 match verify::signed_version(trusted_comment) {
421 Some(signed) => {
422 let matches = crate::release::parse_tag(signed)
423 .is_ok_and(|v| v.cmp_precedence(expected).is_eq());
424 if matches {
425 Ok(())
426 } else {
427 Err(Error::Signature(format!(
428 "file is signed for version {signed}, expected {expected}"
429 )))
430 }
431 }
432 None if self.version_binding == VersionBinding::Required => Err(Error::Signature(
433 "the signature's trusted comment names no `version:`".into(),
434 )),
435 None => Ok(()),
436 }
437 }
438}
439
440/// The integrity inputs published for one downloadable file.
441#[derive(Clone, Copy)]
442pub(super) struct Integrity<'a> {
443 /// What the file is, for error messages (`release` / `delta`).
444 what: &'static str,
445 /// The file's published name, looked up in a checksums file.
446 name: &'a str,
447 sha256: Option<&'a str>,
448 checksums_url: Option<&'a str>,
449 signature: Option<&'a str>,
450 signature_url: Option<&'a str>,
451 ed25519_signature: Option<&'a str>,
452}
453
454impl<'a> Integrity<'a> {
455 pub(super) fn of_release(release: &'a Release) -> Self {
456 Self {
457 what: "release",
458 name: &release.asset.name,
459 sha256: release.sha256.as_deref(),
460 checksums_url: None,
461 signature: release.signature.as_deref(),
462 signature_url: release.signature_url.as_deref(),
463 ed25519_signature: release.ed25519_signature.as_deref(),
464 }
465 }
466
467 #[cfg_attr(
468 all(not(feature = "delta"), not(test)),
469 expect(dead_code, reason = "only deltas are verified this way")
470 )]
471 pub(super) fn of_delta(delta: &'a DeltaAsset) -> Self {
472 Self {
473 what: "delta",
474 name: &delta.asset.name,
475 sha256: delta.sha256.as_deref(),
476 checksums_url: delta.checksums_url.as_deref(),
477 signature: delta.signature.as_deref(),
478 signature_url: delta.signature_url.as_deref(),
479 ed25519_signature: delta.ed25519_signature.as_deref(),
480 }
481 }
482
483 /// Whether a minisign signature is advertised, inline or by URL.
484 fn has_minisign(self) -> bool {
485 non_empty(self.signature).is_some() || non_empty(self.signature_url).is_some()
486 }
487
488 /// Whether a checksum is advertised, inline or in a checksums file.
489 fn has_checksum(self) -> bool {
490 non_empty(self.sha256).is_some() || non_empty(self.checksums_url).is_some()
491 }
492}
493
494/// Trim `s` and treat a blank string as absent.
495fn non_empty(s: Option<&str>) -> Option<&str> {
496 s.map(str::trim).filter(|s| !s.is_empty())
497}
498
499/// The expected SHA-256 of a file: the inline value if present, otherwise
500/// looked up in the checksums file at `checksums_url`.
501fn resolve_checksum(file: Integrity<'_>, http: &HttpOptions) -> Result<Option<String>> {
502 if let Some(sha256) = non_empty(file.sha256) {
503 return Ok(Some(sha256.to_string()));
504 }
505 match non_empty(file.checksums_url) {
506 Some(url) => Ok(verify::sha256_from_sums(
507 &http::get_string(url, &[], Metadata::Sidecar, http)?,
508 file.name,
509 )),
510 None => Ok(None),
511 }
512}
513
514/// The minisign signature for a file: the inline value if present, otherwise
515/// fetched from `signature_url`. `None` when the release advertises neither,
516/// or when a fetched signature body is blank.
517fn resolve_minisign(file: Integrity<'_>, http: &HttpOptions) -> Result<Option<String>> {
518 if let Some(signature) = non_empty(file.signature) {
519 return Ok(Some(signature.to_string()));
520 }
521 if let Some(url) = non_empty(file.signature_url) {
522 let body = http::get_string(url, &[], Metadata::Sidecar, http)?;
523 return Ok(non_empty(Some(&body)).map(str::to_string));
524 }
525 Ok(None)
526}