use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
};
use sha2::{Digest, Sha256};
use crate::registry::ServiceRegistry;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DigestAlgorithm {
#[default]
Sha256,
}
impl DigestAlgorithm {
pub fn name(self) -> &'static str {
match self {
DigestAlgorithm::Sha256 => "sha256",
}
}
pub fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"sha256" | "sha-256" => Some(DigestAlgorithm::Sha256),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PackageDigest {
pub algorithm: DigestAlgorithm,
pub value: String,
}
impl PackageDigest {
pub fn sha256(value: impl AsRef<str>) -> Self {
Self {
algorithm: DigestAlgorithm::Sha256,
value: value.as_ref().trim().to_ascii_lowercase(),
}
}
pub fn parse(value: &str) -> Option<Self> {
let (algorithm, digest) = value.split_once(':')?;
let algorithm = DigestAlgorithm::parse(algorithm)?;
let digest = digest.trim().to_ascii_lowercase();
(!digest.is_empty()).then_some(Self {
algorithm,
value: digest,
})
}
pub fn is_well_formed(&self) -> bool {
let expected = match self.algorithm {
DigestAlgorithm::Sha256 => 64,
};
self.value.len() == expected && self.value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
pub fn to_feed_string(&self) -> String {
format!("{}:{}", self.algorithm.name(), self.value)
}
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex(&hasher.finalize())
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
}
out
}
pub struct DigestVerifier {
expected: PackageDigest,
hasher: Sha256,
len: u64,
}
impl DigestVerifier {
pub fn new(expected: PackageDigest) -> Result<Self, AppUpdateError> {
if !expected.is_well_formed() {
return Err(AppUpdateError::MalformedDigest(expected.to_feed_string()));
}
Ok(Self {
expected,
hasher: Sha256::new(),
len: 0,
})
}
pub fn update(&mut self, chunk: &[u8]) {
self.hasher.update(chunk);
self.len += chunk.len() as u64;
}
pub fn len(&self) -> u64 {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn finish(self) -> Result<(), AppUpdateError> {
let actual = hex(&self.hasher.finalize());
if actual == self.expected.value {
Ok(())
} else {
Err(AppUpdateError::VerificationFailed {
expected: self.expected.value,
actual,
})
}
}
}
pub fn verify_package(bytes: &[u8], digest: &PackageDigest) -> Result<(), AppUpdateError> {
let mut verifier = DigestVerifier::new(digest.clone())?;
verifier.update(bytes);
verifier.finish()
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct UpdatePackage {
pub version: String,
pub download_url: String,
pub size: Option<u64>,
pub digest: Option<PackageDigest>,
pub notes: Option<String>,
}
impl UpdatePackage {
pub fn new(version: impl Into<String>, download_url: impl Into<String>) -> Self {
Self {
version: version.into(),
download_url: download_url.into(),
..Self::default()
}
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn with_digest(mut self, digest: PackageDigest) -> Self {
self.digest = Some(digest);
self
}
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
pub fn is_verifiable(&self) -> bool {
self.digest
.as_ref()
.is_some_and(PackageDigest::is_well_formed)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct GitHubReleaseUpdate {
pub repository: String,
pub current_version: String,
pub asset_suffix: String,
}
impl GitHubReleaseUpdate {
pub fn new(
repository: impl Into<String>,
current_version: impl Into<String>,
asset_suffix: impl Into<String>,
) -> Self {
Self {
repository: repository.into(),
current_version: current_version.into(),
asset_suffix: asset_suffix.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum AppUpdateStatus {
#[default]
Idle,
Checking,
UpToDate,
Available {
package: UpdatePackage,
},
Downloading {
downloaded: u64,
total: Option<u64>,
},
Verifying,
AwaitingConfirmation,
Installing,
Error(String),
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
pub enum AppUpdateError {
#[error("application updates are unavailable on this platform")]
Unsupported,
#[error("application update request failed: {0}")]
Request(String),
#[error("the release feed published no digest for this package, so it cannot be checked")]
Unverifiable,
#[error("the release feed published a digest that cannot be checked: {0}")]
MalformedDigest(String),
#[error("the downloaded package does not match its digest (expected {expected}, got {actual})")]
VerificationFailed {
expected: String,
actual: String,
},
}
pub trait AppUpdater: Send + Sync {
fn capabilities(&self) -> AppUpdateCapabilities {
AppUpdateCapabilities::default()
}
fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
let _ = source;
Err(AppUpdateError::Unsupported)
}
fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
let _ = package;
Err(AppUpdateError::Unsupported)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AppUpdateCapabilities {
pub check: bool,
pub install: bool,
}
pub type AppUpdaterRef = Arc<dyn AppUpdater>;
static PLATFORM_UPDATER: ServiceRegistry<dyn AppUpdater> = ServiceRegistry::new();
pub fn set_platform_app_updater(updater: AppUpdaterRef) {
PLATFORM_UPDATER.set(updater);
}
pub fn clear_platform_app_updater() {
PLATFORM_UPDATER.clear();
}
pub fn app_update_capabilities() -> AppUpdateCapabilities {
PLATFORM_UPDATER
.get()
.map(|updater| updater.capabilities())
.unwrap_or_default()
}
pub fn app_updates_supported() -> bool {
app_update_capabilities().install
}
pub fn app_update_checks_supported() -> bool {
app_update_capabilities().check
}
fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
set_app_update_status(AppUpdateStatus::Error(error.to_string()));
Err(error)
}
pub fn check_for_app_update(source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
let Some(updater) = PLATFORM_UPDATER.get() else {
return publish_failure(AppUpdateError::Unsupported);
};
if !updater.capabilities().check {
return publish_failure(AppUpdateError::Unsupported);
}
set_app_update_status(AppUpdateStatus::Checking);
updater.check(source).inspect_err(|error| {
set_app_update_status(AppUpdateStatus::Error(error.to_string()));
})
}
pub fn install_app_update(package: &UpdatePackage) -> Result<(), AppUpdateError> {
let Some(updater) = PLATFORM_UPDATER.get() else {
return publish_failure(AppUpdateError::Unsupported);
};
if !updater.capabilities().install {
return publish_failure(AppUpdateError::Unsupported);
}
let error = match &package.digest {
None => Some(AppUpdateError::Unverifiable),
Some(digest) if !digest.is_well_formed() => {
Some(AppUpdateError::MalformedDigest(digest.to_feed_string()))
}
Some(_) => None,
};
if let Some(error) = error {
return publish_failure(error);
}
set_app_update_status(AppUpdateStatus::Downloading {
downloaded: 0,
total: package.size,
});
updater.install(package).inspect_err(|error| {
set_app_update_status(AppUpdateStatus::Error(error.to_string()));
})
}
fn status_slot() -> &'static Mutex<AppUpdateStatus> {
static STATUS: OnceLock<Mutex<AppUpdateStatus>> = OnceLock::new();
STATUS.get_or_init(|| Mutex::new(AppUpdateStatus::Idle))
}
pub fn app_update_status() -> AppUpdateStatus {
status_slot().lock().map_or_else(
|poisoned| poisoned.into_inner().clone(),
|status| status.clone(),
)
}
#[cfg(not(target_arch = "wasm32"))]
type Observer = Arc<dyn Fn(AppUpdateStatus) + Send + Sync>;
#[cfg(target_arch = "wasm32")]
type Observer = std::rc::Rc<dyn Fn(AppUpdateStatus)>;
#[cfg(not(target_arch = "wasm32"))]
fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
}
#[cfg(target_arch = "wasm32")]
thread_local! {
static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
}
static NEXT_OBSERVER_ID: AtomicU64 = AtomicU64::new(1);
pub struct AppUpdateObserver {
id: u64,
}
impl Drop for AppUpdateObserver {
fn drop(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if let Ok(mut observers) = observers().lock() {
observers.retain(|(id, _)| *id != self.id);
}
#[cfg(target_arch = "wasm32")]
OBSERVERS.with(|observers| observers.borrow_mut().retain(|(id, _)| *id != self.id));
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn observe_app_update_status(
observer: impl Fn(AppUpdateStatus) + Send + Sync + 'static,
) -> AppUpdateObserver {
let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
let observer: Observer = Arc::new(observer);
if let Ok(mut observers) = observers().lock() {
observers.push((id, Arc::clone(&observer)));
}
observer(app_update_status());
AppUpdateObserver { id }
}
#[cfg(target_arch = "wasm32")]
pub fn observe_app_update_status(
observer: impl Fn(AppUpdateStatus) + 'static,
) -> AppUpdateObserver {
let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
let observer: Observer = std::rc::Rc::new(observer);
OBSERVERS.with(|observers| {
observers
.borrow_mut()
.push((id, std::rc::Rc::clone(&observer)));
});
observer(app_update_status());
AppUpdateObserver { id }
}
pub fn set_app_update_status(status: AppUpdateStatus) {
if let Ok(mut current) = status_slot().lock() {
if *current == status {
return;
}
*current = status.clone();
}
#[cfg(not(target_arch = "wasm32"))]
let observers = observers()
.lock()
.map(|observers| {
observers
.iter()
.map(|(_, observer)| Arc::clone(observer))
.collect::<Vec<_>>()
})
.unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let observers = OBSERVERS.with(|observers| {
observers
.borrow()
.iter()
.map(|(_, observer)| std::rc::Rc::clone(observer))
.collect::<Vec<_>>()
});
for observer in observers {
observer(status.clone());
}
}
#[cfg(test)]
mod tests {
use std::sync::{PoisonError, atomic::AtomicUsize};
use super::*;
struct RecordingUpdater {
checks: AtomicUsize,
installed: Mutex<Vec<UpdatePackage>>,
}
impl RecordingUpdater {
fn new() -> Self {
Self {
checks: AtomicUsize::new(0),
installed: Mutex::new(Vec::new()),
}
}
fn installs(&self) -> Vec<UpdatePackage> {
self.installed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
}
impl AppUpdater for RecordingUpdater {
fn capabilities(&self) -> AppUpdateCapabilities {
AppUpdateCapabilities {
check: true,
install: true,
}
}
fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
self.checks.fetch_add(1, Ordering::Relaxed);
Ok(())
}
fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
self.installed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(package.clone());
Ok(())
}
}
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
#[test]
fn the_digest_is_the_one_every_other_implementation_computes() {
assert_eq!(sha256_hex(b""), EMPTY_SHA256);
assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
}
#[test]
fn a_feed_digest_is_read_in_the_form_feeds_publish_it() {
let digest = PackageDigest::parse(&format!("sha256:{}", ABC_SHA256.to_uppercase()))
.expect("a sha256 digest");
assert_eq!(digest.algorithm, DigestAlgorithm::Sha256);
assert_eq!(digest.value, ABC_SHA256, "case is normalised on the way in");
assert_eq!(digest.to_feed_string(), format!("sha256:{ABC_SHA256}"));
assert!(digest.is_well_formed());
}
#[test]
fn a_digest_this_framework_cannot_check_is_refused_rather_than_ignored() {
assert_eq!(PackageDigest::parse("md5:abcdef"), None);
assert_eq!(PackageDigest::parse("sha256:"), None);
assert_eq!(PackageDigest::parse("no-algorithm"), None);
assert!(!PackageDigest::sha256("not hexadecimal").is_well_formed());
assert!(!PackageDigest::sha256("abcd").is_well_formed(), "too short");
assert!(matches!(
DigestVerifier::new(PackageDigest::sha256("abcd")),
Err(AppUpdateError::MalformedDigest(_))
));
}
#[test]
fn a_package_that_matches_its_digest_verifies() {
assert_eq!(
verify_package(b"abc", &PackageDigest::sha256(ABC_SHA256)),
Ok(())
);
}
#[test]
fn a_package_that_does_not_match_reports_both_digests() {
let error = verify_package(b"abd", &PackageDigest::sha256(ABC_SHA256))
.expect_err("a changed byte must not verify");
match error {
AppUpdateError::VerificationFailed { expected, actual } => {
assert_eq!(expected, ABC_SHA256);
assert_ne!(actual, ABC_SHA256);
assert_eq!(actual, sha256_hex(b"abd"));
}
other => panic!("expected a verification failure, got {other}"),
}
}
#[test]
fn a_package_read_in_chunks_verifies_the_same_as_one_read_whole() {
let mut verifier =
DigestVerifier::new(PackageDigest::sha256(ABC_SHA256)).expect("a well-formed digest");
assert!(verifier.is_empty());
verifier.update(b"a");
verifier.update(b"b");
verifier.update(b"c");
assert_eq!(verifier.len(), 3);
assert_eq!(verifier.finish(), Ok(()));
}
#[test]
fn a_package_carries_what_the_feed_promised_about_it() {
let package = UpdatePackage::new("1.2.3", "https://example.test/app.apk")
.with_size(4096)
.with_digest(PackageDigest::sha256(ABC_SHA256))
.with_notes("Fixes the thing");
assert_eq!(package.version, "1.2.3");
assert_eq!(package.size, Some(4096));
assert!(package.is_verifiable());
assert_eq!(package.notes.as_deref(), Some("Fixes the thing"));
assert!(
!UpdatePackage::new("1.2.3", "https://example.test/app.apk").is_verifiable(),
"a feed that published no digest leaves nothing to check against"
);
}
#[test]
fn request_builds_typed_source() {
let source = GitHubReleaseUpdate::new("owner/app", "1.2.3", ".apk");
assert_eq!(source.repository, "owner/app");
assert_eq!(source.current_version, "1.2.3");
assert_eq!(source.asset_suffix, ".apk");
}
#[test]
fn operations_publish_and_forward() {
let _guard = crate::registry::test_service_guard();
let updater = Arc::new(RecordingUpdater::new());
set_platform_app_updater(updater.clone());
assert!(app_updates_supported());
check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")).unwrap();
assert_eq!(updater.checks.load(Ordering::Relaxed), 1);
assert_eq!(app_update_status(), AppUpdateStatus::Checking);
let package = UpdatePackage::new("2", "https://example.test/app.apk")
.with_size(4096)
.with_digest(PackageDigest::sha256(sha256_hex(b"package")));
install_app_update(&package).unwrap();
assert_eq!(updater.installs(), vec![package]);
assert_eq!(
app_update_status(),
AppUpdateStatus::Downloading {
downloaded: 0,
total: Some(4096)
},
"the size the feed published is reported before the first byte arrives"
);
clear_platform_app_updater();
assert!(!app_updates_supported());
}
#[test]
fn a_package_with_an_uncheckable_digest_is_refused_before_it_is_downloaded() {
let _guard = crate::registry::test_service_guard();
let updater = Arc::new(RecordingUpdater::new());
set_platform_app_updater(updater.clone());
let package = UpdatePackage::new("2", "https://example.test/app.apk")
.with_digest(PackageDigest::sha256("not-a-digest"));
assert!(matches!(
install_app_update(&package),
Err(AppUpdateError::MalformedDigest(_))
));
assert!(updater.installs().is_empty());
assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
clear_platform_app_updater();
}
#[test]
fn a_host_that_cannot_update_says_so_through_the_status_and_not_only_the_result() {
let _guard = crate::registry::test_service_guard();
clear_platform_app_updater();
set_app_update_status(AppUpdateStatus::Idle);
assert_eq!(
check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")),
Err(AppUpdateError::Unsupported)
);
assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
struct CheckOnlyUpdater;
impl AppUpdater for CheckOnlyUpdater {
fn capabilities(&self) -> AppUpdateCapabilities {
AppUpdateCapabilities {
check: true,
install: false,
}
}
fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
Ok(())
}
}
set_platform_app_updater(Arc::new(CheckOnlyUpdater));
set_app_update_status(AppUpdateStatus::Idle);
let package = UpdatePackage::new("2", "https://example.test/app.apk")
.with_digest(PackageDigest::sha256(sha256_hex(b"package")));
assert_eq!(
install_app_update(&package),
Err(AppUpdateError::Unsupported)
);
assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
assert!(app_update_checks_supported());
assert!(!app_updates_supported());
clear_platform_app_updater();
}
#[test]
fn a_package_with_no_digest_at_all_never_reaches_the_installer() {
let _guard = crate::registry::test_service_guard();
let updater = Arc::new(RecordingUpdater::new());
set_platform_app_updater(updater.clone());
let package = UpdatePackage::new("2", "https://example.test/app.apk");
assert!(!package.is_verifiable());
assert_eq!(
install_app_update(&package),
Err(AppUpdateError::Unverifiable)
);
assert!(updater.installs().is_empty());
assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
clear_platform_app_updater();
}
#[test]
fn observer_receives_current_and_changed_status() {
let _guard = crate::registry::test_service_guard();
set_app_update_status(AppUpdateStatus::Idle);
let seen = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&seen);
let observer = observe_app_update_status(move |status| {
captured
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(status);
});
set_app_update_status(AppUpdateStatus::Verifying);
set_app_update_status(AppUpdateStatus::Installing);
assert_eq!(
*seen.lock().unwrap_or_else(PoisonError::into_inner),
vec![
AppUpdateStatus::Idle,
AppUpdateStatus::Verifying,
AppUpdateStatus::Installing
]
);
drop(observer);
set_app_update_status(AppUpdateStatus::Idle);
}
}