use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use semver::Version;
use crate::error::{Error, Result};
use crate::install::{self, Installed};
use crate::release::Release;
use crate::source::UpdateSource;
use crate::{http, verify};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Verification {
#[default]
BestEffort,
Off,
Checksum,
Signature,
Strict,
}
impl Verification {
fn skips_all(self) -> bool {
matches!(self, Self::Off)
}
fn requires_signature(self) -> bool {
matches!(self, Self::Signature | Self::Strict)
}
fn requires_checksum(self) -> bool {
matches!(self, Self::Checksum | Self::Strict)
}
}
#[non_exhaustive]
pub struct EngineConfig {
pub current_version: Version,
pub minisign_public_key: Option<String>,
pub ed25519_public_key: Option<String>,
pub verification: Verification,
pub install_root: Option<PathBuf>,
pub download_dir: Option<PathBuf>,
}
impl EngineConfig {
pub fn new(current_version: Version) -> Self {
Self {
current_version,
minisign_public_key: None,
ed25519_public_key: None,
verification: Verification::default(),
install_root: None,
download_dir: None,
}
}
#[must_use]
pub fn minisign_public_key(mut self, key: impl Into<String>) -> Self {
self.minisign_public_key = Some(key.into());
self
}
#[must_use]
pub fn ed25519_public_key(mut self, key: impl Into<String>) -> Self {
self.ed25519_public_key = Some(key.into());
self
}
#[must_use]
pub fn verification(mut self, verification: Verification) -> Self {
self.verification = verification;
self
}
fn precheck(&self, release: &Release) -> Result<()> {
if self.verification.requires_signature() && !self.can_verify_signature_of(release) {
let reason = if self.minisign_public_key.is_none() && self.ed25519_public_key.is_none()
{
"policy requires a signature, but no minisign or Ed25519 public key is configured"
} else {
"policy requires a signature, but the release publishes none that a configured key can verify"
};
return Err(Error::VerificationRequired(reason.into()));
}
if self.verification.requires_checksum() && non_empty(release.sha256.as_deref()).is_none() {
return Err(Error::VerificationRequired(
"policy requires a SHA-256 checksum, but the release publishes none".into(),
));
}
Ok(())
}
fn can_verify_signature_of(&self, release: &Release) -> bool {
let minisign = self.minisign_public_key.is_some() && release_has_minisign(release);
let ed25519 = self.ed25519_public_key.is_some()
&& non_empty(release.ed25519_signature.as_deref()).is_some();
minisign || ed25519
}
#[must_use]
pub fn install_root(mut self, path: impl Into<PathBuf>) -> Self {
self.install_root = Some(path.into());
self
}
#[must_use]
pub fn download_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.download_dir = Some(path.into());
self
}
}
pub struct UpdateEngine<S: UpdateSource> {
source: S,
config: EngineConfig,
}
impl<S: UpdateSource> UpdateEngine<S> {
pub fn new(source: S, config: EngineConfig) -> Self {
Self { source, config }
}
pub fn current_version(&self) -> &Version {
&self.config.current_version
}
pub fn check(&self) -> Result<Option<Release>> {
let latest = self.source.fetch_latest()?;
if latest.version > self.config.current_version {
self.config.precheck(&latest)?;
Ok(Some(latest))
} else {
Ok(None)
}
}
pub fn download(
&self,
release: &Release,
mut progress: impl FnMut(u64, Option<u64>),
) -> Result<PathBuf> {
let dir = match &self.config.download_dir {
Some(dir) => {
std::fs::create_dir_all(dir)?;
dir.clone()
}
None => default_download_dir()?,
};
let artifact = dir.join(&release.asset.name);
http::download(&release.asset.url, &[], &artifact, &mut progress)?;
self.verify_artifact(&artifact, release)?;
Ok(artifact)
}
fn verify_artifact(&self, artifact: &Path, release: &Release) -> Result<()> {
let policy = self.config.verification;
if policy.skips_all() {
return Ok(());
}
self.config.precheck(release)?;
if let Some(expected) = non_empty(release.sha256.as_deref()) {
verify::verify_sha256(artifact, expected)?;
}
let mut verified_signature = false;
if let (Some(key), Some(signature)) = (
&self.config.ed25519_public_key,
non_empty(release.ed25519_signature.as_deref()),
) {
verify::verify_ed25519(artifact, key, signature)?;
verified_signature = true;
}
if let Some(key) = &self.config.minisign_public_key {
if let Some(signature) = resolve_minisign(release)? {
verify::verify_minisign(artifact, key, &signature)?;
verified_signature = true;
}
}
if policy.requires_signature() && !verified_signature {
return Err(Error::VerificationRequired(
"policy requires a signature, but the published signature is empty".into(),
));
}
Ok(())
}
pub fn install(&self, artifact: &Path) -> Result<Installed> {
let root = match &self.config.install_root {
Some(root) => root.clone(),
None => install::current_install_root()?,
};
install::install(artifact, &root)
}
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)?))
}
}
fn default_download_dir() -> Result<PathBuf> {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let dir = std::env::temp_dir().join(format!("gpui-updater-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn non_empty(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|s| !s.is_empty())
}
fn release_has_minisign(release: &Release) -> bool {
non_empty(release.signature.as_deref()).is_some()
|| non_empty(release.signature_url.as_deref()).is_some()
}
fn resolve_minisign(release: &Release) -> Result<Option<String>> {
if let Some(signature) = non_empty(release.signature.as_deref()) {
return Ok(Some(signature.to_string()));
}
if let Some(url) = non_empty(release.signature_url.as_deref()) {
let body = http::get_string(url, &[])?;
return Ok(non_empty(Some(&body)).map(str::to_string));
}
Ok(None)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::release::Asset;
fn release(
sha256: Option<&str>,
signature: Option<&str>,
signature_url: Option<&str>,
) -> Release {
let mut release = Release::new(
Version::new(1, 0, 0),
Asset::new("app.dmg", "https://example.test/app.dmg", 0),
);
release.signature = signature.map(str::to_string);
release.signature_url = signature_url.map(str::to_string);
release.sha256 = sha256.map(str::to_string);
release
}
fn ed25519_release(signature: &str) -> Release {
release(None, None, None).with_ed25519_signature(signature)
}
fn config(verification: Verification, key: Option<&str>) -> EngineConfig {
let mut config = EngineConfig::new(Version::new(0, 1, 0)).verification(verification);
if let Some(key) = key {
config = config.minisign_public_key(key);
}
config
}
#[test]
fn best_effort_accepts_unverifiable_release() {
assert!(
config(Verification::BestEffort, None)
.precheck(&release(None, None, None))
.is_ok()
);
}
#[test]
fn checksum_policy_requires_published_checksum() {
let cfg = config(Verification::Checksum, None);
assert!(matches!(
cfg.precheck(&release(None, None, None)),
Err(Error::VerificationRequired(_))
));
assert!(cfg.precheck(&release(Some("abc"), None, None)).is_ok());
}
#[test]
fn signature_policy_requires_public_key() {
let cfg = config(Verification::Signature, None);
assert!(matches!(
cfg.precheck(&release(
None,
None,
Some("https://example.test/app.dmg.minisig")
)),
Err(Error::VerificationRequired(_))
));
}
#[test]
fn signature_policy_requires_advertised_signature() {
let cfg = config(Verification::Signature, Some("pubkey"));
assert!(matches!(
cfg.precheck(&release(Some("abc"), None, None)),
Err(Error::VerificationRequired(_))
));
assert!(matches!(
cfg.precheck(&release(None, Some(" "), Some(""))),
Err(Error::VerificationRequired(_))
));
assert!(
cfg.precheck(&release(None, Some("trusted comment: …"), None))
.is_ok()
);
assert!(
cfg.precheck(&release(
None,
None,
Some("https://example.test/app.dmg.minisig")
))
.is_ok()
);
}
#[test]
fn strict_policy_requires_both_signature_and_checksum() {
let cfg = config(Verification::Strict, Some("pubkey"));
assert!(matches!(
cfg.precheck(&release(None, Some("sig"), None)),
Err(Error::VerificationRequired(_))
));
assert!(
cfg.precheck(&release(Some("abc"), Some("sig"), None))
.is_ok()
);
}
#[test]
fn signature_policy_accepts_an_ed25519_signature_with_its_key() {
let cfg = config(Verification::Signature, None).ed25519_public_key("edkey");
assert!(cfg.precheck(&ed25519_release("edsig")).is_ok());
assert!(matches!(
cfg.precheck(&ed25519_release(" ")),
Err(Error::VerificationRequired(_))
));
}
#[test]
fn signature_policy_needs_a_key_for_the_published_signature_kind() {
let cfg = config(Verification::Signature, Some("pubkey"));
assert!(matches!(
cfg.precheck(&ed25519_release("edsig")),
Err(Error::VerificationRequired(_))
));
let cfg = config(Verification::Signature, None).ed25519_public_key("edkey");
assert!(matches!(
cfg.precheck(&release(None, Some("sig"), None)),
Err(Error::VerificationRequired(_))
));
}
#[test]
fn verifies_ed25519_signed_artifacts_and_rejects_tampering() {
let fixture = crate::verify::test_support::Ed25519Fixture::new(b"update payload");
let engine = UpdateEngine::new(
PanicSource,
config(Verification::Signature, None).ed25519_public_key(&fixture.public_key),
);
let signed = ed25519_release(&fixture.signature);
assert!(engine.verify_artifact(&fixture.path, &signed).is_ok());
std::fs::write(&fixture.path, b"update payloaD").unwrap();
assert!(matches!(
engine.verify_artifact(&fixture.path, &signed),
Err(Error::Signature(_))
));
}
#[test]
fn off_policy_skips_verification_entirely() {
let engine = UpdateEngine::new(PanicSource, config(Verification::Off, None));
let path = Path::new("/nonexistent/app.dmg");
assert!(
engine
.verify_artifact(path, &release(Some("abc"), None, None))
.is_ok()
);
}
#[test]
fn blank_checksum_is_treated_as_absent_not_verified() {
let engine = UpdateEngine::new(PanicSource, config(Verification::BestEffort, None));
let path = Path::new("/nonexistent/app.dmg");
for blank in ["", " "] {
assert!(
engine
.verify_artifact(path, &release(Some(blank), None, None))
.is_ok(),
"blank sha256 {blank:?} should be treated as absent"
);
}
}
struct PanicSource;
impl UpdateSource for PanicSource {
fn fetch_latest(&self) -> Result<Release> {
unreachable!("policy tests do not fetch")
}
}
}