Skip to main content

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}