Skip to main content

cranpose_services/
app_update.rs

1//! Typed application update discovery, verification, and platform installation.
2//!
3//! An update is the one download an application performs that can replace the
4//! application, so what arrives is checked against what was promised before it
5//! reaches a platform installer. The check lives here, once, rather than in each
6//! platform's installer: a digest computed four different ways is four chances
7//! to compute it wrongly, and one of them will be the one nobody tested.
8
9use std::sync::{Arc, Mutex, OnceLock, atomic::Ordering};
10
11use portable_atomic::AtomicU64;
12use sha2::{Digest, Sha256};
13
14use crate::registry::ServiceRegistry;
15
16/// How a package's bytes are checked against what the release promised.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub enum DigestAlgorithm {
19    /// SHA-256, which is what release feeds publish.
20    #[default]
21    Sha256,
22}
23
24impl DigestAlgorithm {
25    /// The name a release feed writes, and the name a platform's own digest API
26    /// answers to.
27    pub fn name(self) -> &'static str {
28        match self {
29            DigestAlgorithm::Sha256 => "sha256",
30        }
31    }
32
33    /// Reads an algorithm a release feed named, or `None` for one this
34    /// framework cannot compute — which is refused rather than skipped, because
35    /// a digest nobody checks is worse than no digest at all.
36    pub fn parse(name: &str) -> Option<Self> {
37        match name.trim().to_ascii_lowercase().as_str() {
38            "sha256" | "sha-256" => Some(DigestAlgorithm::Sha256),
39            _ => None,
40        }
41    }
42}
43
44/// The digest a downloaded package must match.
45#[derive(Clone, Debug, PartialEq, Eq, Hash)]
46pub struct PackageDigest {
47    pub algorithm: DigestAlgorithm,
48    /// Lower-case hexadecimal.
49    pub value: String,
50}
51
52impl PackageDigest {
53    /// A SHA-256 digest, from hexadecimal in either case.
54    pub fn sha256(value: impl AsRef<str>) -> Self {
55        Self {
56            algorithm: DigestAlgorithm::Sha256,
57            value: value.as_ref().trim().to_ascii_lowercase(),
58        }
59    }
60
61    /// Reads the `sha256:<hex>` form release feeds publish.
62    pub fn parse(value: &str) -> Option<Self> {
63        let (algorithm, digest) = value.split_once(':')?;
64        let algorithm = DigestAlgorithm::parse(algorithm)?;
65        let digest = digest.trim().to_ascii_lowercase();
66        (!digest.is_empty()).then_some(Self {
67            algorithm,
68            value: digest,
69        })
70    }
71
72    /// Whether this digest could be one: the right length, and hexadecimal.
73    ///
74    /// A malformed digest is refused before a download starts rather than after
75    /// it, so nobody waits for two hundred megabytes to learn the release feed
76    /// was misconfigured.
77    pub fn is_well_formed(&self) -> bool {
78        let expected = match self.algorithm {
79            DigestAlgorithm::Sha256 => 64,
80        };
81        self.value.len() == expected && self.value.bytes().all(|byte| byte.is_ascii_hexdigit())
82    }
83
84    /// The `sha256:<hex>` form.
85    pub fn to_feed_string(&self) -> String {
86        format!("{}:{}", self.algorithm.name(), self.value)
87    }
88}
89
90/// The SHA-256 of `bytes`, as lower-case hexadecimal.
91pub fn sha256_hex(bytes: &[u8]) -> String {
92    let mut hasher = Sha256::new();
93    hasher.update(bytes);
94    hex(&hasher.finalize())
95}
96
97fn hex(bytes: &[u8]) -> String {
98    let mut out = String::with_capacity(bytes.len() * 2);
99    for byte in bytes {
100        out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
101        out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
102    }
103    out
104}
105
106/// Checks a package as it is read, so a package too large to hold in memory is
107/// still checked.
108///
109/// An installer feeds every chunk it writes through this and calls
110/// [`DigestVerifier::finish`] before committing; nothing installs a package
111/// whose bytes were never seen in full.
112pub struct DigestVerifier {
113    expected: PackageDigest,
114    hasher: Sha256,
115    len: u64,
116}
117
118impl DigestVerifier {
119    /// A verifier for `expected`, or an error when the digest is malformed.
120    pub fn new(expected: PackageDigest) -> Result<Self, AppUpdateError> {
121        if !expected.is_well_formed() {
122            return Err(AppUpdateError::MalformedDigest(expected.to_feed_string()));
123        }
124        Ok(Self {
125            expected,
126            hasher: Sha256::new(),
127            len: 0,
128        })
129    }
130
131    /// Feeds the next chunk of the package.
132    pub fn update(&mut self, chunk: &[u8]) {
133        self.hasher.update(chunk);
134        self.len += chunk.len() as u64;
135    }
136
137    /// How many bytes have been read so far.
138    pub fn len(&self) -> u64 {
139        self.len
140    }
141
142    /// Whether nothing has been read yet.
143    pub fn is_empty(&self) -> bool {
144        self.len == 0
145    }
146
147    /// Checks what was read against what was promised.
148    pub fn finish(self) -> Result<(), AppUpdateError> {
149        let actual = hex(&self.hasher.finalize());
150        if actual == self.expected.value {
151            Ok(())
152        } else {
153            Err(AppUpdateError::VerificationFailed {
154                expected: self.expected.value,
155                actual,
156            })
157        }
158    }
159}
160
161/// Checks a package held in memory against `digest`.
162pub fn verify_package(bytes: &[u8], digest: &PackageDigest) -> Result<(), AppUpdateError> {
163    let mut verifier = DigestVerifier::new(digest.clone())?;
164    verifier.update(bytes);
165    verifier.finish()
166}
167
168/// A package an update would install.
169#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
170pub struct UpdatePackage {
171    /// The version this package installs.
172    pub version: String,
173    /// Where the package is downloaded from.
174    pub download_url: String,
175    /// How large it is, when the release feed says.
176    pub size: Option<u64>,
177    /// What its bytes must hash to.
178    ///
179    /// `None` means the release feed published none, and
180    /// [`install_app_update`] refuses such a package: the platform's own
181    /// signature check catches a package signed by someone else, but not one
182    /// that arrived corrupted, and this is the one download that replaces the
183    /// application. A feed with no digest is a feed to fix.
184    pub digest: Option<PackageDigest>,
185    /// Release notes, when the feed carries them.
186    pub notes: Option<String>,
187}
188
189impl UpdatePackage {
190    /// A package at `download_url` installing `version`.
191    pub fn new(version: impl Into<String>, download_url: impl Into<String>) -> Self {
192        Self {
193            version: version.into(),
194            download_url: download_url.into(),
195            ..Self::default()
196        }
197    }
198
199    pub fn with_size(mut self, size: u64) -> Self {
200        self.size = Some(size);
201        self
202    }
203
204    pub fn with_digest(mut self, digest: PackageDigest) -> Self {
205        self.digest = Some(digest);
206        self
207    }
208
209    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
210        self.notes = Some(notes.into());
211        self
212    }
213
214    /// Whether this package can be checked against what the feed promised.
215    pub fn is_verifiable(&self) -> bool {
216        self.digest
217            .as_ref()
218            .is_some_and(PackageDigest::is_well_formed)
219    }
220}
221
222/// A GitHub release feed used to discover an application package.
223#[derive(Clone, Debug, PartialEq, Eq, Hash)]
224pub struct GitHubReleaseUpdate {
225    /// Repository in `owner/name` form.
226    pub repository: String,
227    /// Version of the running application.
228    pub current_version: String,
229    /// File-name suffix selected from the release assets, such as `.apk`.
230    pub asset_suffix: String,
231}
232
233impl GitHubReleaseUpdate {
234    /// Creates a GitHub release request.
235    pub fn new(
236        repository: impl Into<String>,
237        current_version: impl Into<String>,
238        asset_suffix: impl Into<String>,
239    ) -> Self {
240        Self {
241            repository: repository.into(),
242            current_version: current_version.into(),
243            asset_suffix: asset_suffix.into(),
244        }
245    }
246}
247
248/// Observable state of the application update flow.
249#[derive(Clone, Debug, Default, PartialEq, Eq)]
250pub enum AppUpdateStatus {
251    /// No operation has started.
252    #[default]
253    Idle,
254    /// The release feed is being queried.
255    Checking,
256    /// The running application is current.
257    UpToDate,
258    /// A package can be installed.
259    Available {
260        /// What the release feed offers, including its size and digest when it
261        /// published them.
262        package: UpdatePackage,
263    },
264    /// A package is being transferred to the platform installer.
265    Downloading {
266        /// Bytes transferred so far.
267        downloaded: u64,
268        /// Total bytes when supplied by the server.
269        total: Option<u64>,
270    },
271    /// The transfer finished and the package is being checked against the
272    /// digest the release feed published.
273    Verifying,
274    /// The platform is asking the user to approve installation.
275    AwaitingConfirmation,
276    /// The platform installer accepted the package.
277    Installing,
278    /// The operation could not continue.
279    Error(String),
280}
281
282/// Failure to start an update operation.
283#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
284pub enum AppUpdateError {
285    /// This platform has no registered installer.
286    #[error("application updates are unavailable on this platform")]
287    Unsupported,
288    /// The platform rejected the request before work started.
289    #[error("application update request failed: {0}")]
290    Request(String),
291    /// The release feed published no digest for this package.
292    ///
293    /// Refused rather than installed: this is an application replacing itself
294    /// with bytes off the network, and bytes nobody checked are bytes nobody
295    /// checked whether or not the feed mentioned it. A feed that publishes no
296    /// digest is a feed to fix, not a check to skip.
297    #[error("the release feed published no digest for this package, so it cannot be checked")]
298    Unverifiable,
299    /// The release feed published a digest this framework cannot check.
300    ///
301    /// Refused rather than ignored: a digest nobody checks reads as a package
302    /// that was verified.
303    #[error("the release feed published a digest that cannot be checked: {0}")]
304    MalformedDigest(String),
305    /// What arrived is not what the release feed promised.
306    #[error("the downloaded package does not match its digest (expected {expected}, got {actual})")]
307    VerificationFailed {
308        /// The digest the release feed published.
309        expected: String,
310        /// The digest the bytes that arrived actually have.
311        actual: String,
312    },
313}
314
315/// Platform implementation for update discovery and package installation.
316pub trait AppUpdater: Send + Sync {
317    /// What this backend can do.
318    ///
319    /// Defaults to neither, so a backend states what it can do rather than
320    /// inheriting a claim: the two entry points below refuse a half a backend
321    /// has not claimed, and a backend that forgot to declare one is refused
322    /// rather than allowed to fail at the platform boundary.
323    fn capabilities(&self) -> AppUpdateCapabilities {
324        AppUpdateCapabilities::default()
325    }
326
327    /// Starts release discovery. Progress is published through
328    /// [`set_app_update_status`].
329    fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
330        let _ = source;
331        Err(AppUpdateError::Unsupported)
332    }
333
334    /// Transfers a package to the platform installer.
335    ///
336    /// An implementation checks the package against
337    /// [`UpdatePackage::digest`] before committing it — nothing is installed
338    /// whose bytes were not the ones the release feed promised.
339    fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
340        let _ = package;
341        Err(AppUpdateError::Unsupported)
342    }
343}
344
345/// What an update backend can do on this platform.
346///
347/// The two halves are separate because a platform can genuinely have one
348/// without the other: an iOS application may discover that a newer version
349/// exists and send the reader to the store, while installing a replacement
350/// binary is something the platform does not allow it to do at all. Reporting
351/// one flag for both would make `check` look unavailable where it works, or
352/// make `install` look available where it can only fail.
353#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
354pub struct AppUpdateCapabilities {
355    /// Whether this platform can discover a newer release.
356    pub check: bool,
357    /// Whether this platform can install one.
358    pub install: bool,
359}
360
361/// Shared updater service.
362pub type AppUpdaterRef = Arc<dyn AppUpdater>;
363
364static PLATFORM_UPDATER: ServiceRegistry<dyn AppUpdater> = ServiceRegistry::new();
365
366/// Installs the platform updater.
367pub fn set_platform_app_updater(updater: AppUpdaterRef) {
368    PLATFORM_UPDATER.set(updater);
369}
370
371/// Removes the platform updater.
372pub fn clear_platform_app_updater() {
373    PLATFORM_UPDATER.clear();
374}
375
376/// What this host can do about application updates.
377pub fn app_update_capabilities() -> AppUpdateCapabilities {
378    PLATFORM_UPDATER
379        .get()
380        .map(|updater| updater.capabilities())
381        .unwrap_or_default()
382}
383
384/// Returns whether this host can install application updates.
385pub fn app_updates_supported() -> bool {
386    app_update_capabilities().install
387}
388
389/// Returns whether this host can discover a newer release.
390///
391/// A host may answer yes here and no to [`app_updates_supported`]: knowing an
392/// update exists is what lets an application point at the store it cannot
393/// install from itself.
394pub fn app_update_checks_supported() -> bool {
395    app_update_capabilities().check
396}
397
398fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
399    set_app_update_status(AppUpdateStatus::Error(error.to_string()));
400    Err(error)
401}
402
403/// Starts update discovery and publishes the initial state.
404pub fn check_for_app_update(source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
405    let Some(updater) = PLATFORM_UPDATER.get() else {
406        return publish_failure(AppUpdateError::Unsupported);
407    };
408    if !updater.capabilities().check {
409        return publish_failure(AppUpdateError::Unsupported);
410    }
411    set_app_update_status(AppUpdateStatus::Checking);
412    updater.check(source).inspect_err(|error| {
413        set_app_update_status(AppUpdateStatus::Error(error.to_string()));
414    })
415}
416
417/// Starts package installation and publishes the initial transfer state.
418///
419/// A digest the release feed published but this framework cannot check is
420/// refused here, before anything is downloaded: nobody waits for two hundred
421/// megabytes to learn the feed was misconfigured, and nothing reaches an
422/// installer unchecked because its digest was unreadable.
423pub fn install_app_update(package: &UpdatePackage) -> Result<(), AppUpdateError> {
424    let Some(updater) = PLATFORM_UPDATER.get() else {
425        return publish_failure(AppUpdateError::Unsupported);
426    };
427    if !updater.capabilities().install {
428        return publish_failure(AppUpdateError::Unsupported);
429    }
430    let error = match &package.digest {
431        None => Some(AppUpdateError::Unverifiable),
432        Some(digest) if !digest.is_well_formed() => {
433            Some(AppUpdateError::MalformedDigest(digest.to_feed_string()))
434        }
435        Some(_) => None,
436    };
437    if let Some(error) = error {
438        return publish_failure(error);
439    }
440    set_app_update_status(AppUpdateStatus::Downloading {
441        downloaded: 0,
442        total: package.size,
443    });
444    updater.install(package).inspect_err(|error| {
445        set_app_update_status(AppUpdateStatus::Error(error.to_string()));
446    })
447}
448
449fn status_slot() -> &'static Mutex<AppUpdateStatus> {
450    static STATUS: OnceLock<Mutex<AppUpdateStatus>> = OnceLock::new();
451    STATUS.get_or_init(|| Mutex::new(AppUpdateStatus::Idle))
452}
453
454/// Returns the latest update state.
455pub fn app_update_status() -> AppUpdateStatus {
456    status_slot().lock().map_or_else(
457        |poisoned| poisoned.into_inner().clone(),
458        |status| status.clone(),
459    )
460}
461
462#[cfg(not(target_arch = "wasm32"))]
463type Observer = Arc<dyn Fn(AppUpdateStatus) + Send + Sync>;
464
465#[cfg(not(target_arch = "wasm32"))]
466fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
467    static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
468    OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
469}
470
471static NEXT_OBSERVER_ID: AtomicU64 = AtomicU64::new(1);
472
473/// Registration returned by [`observe_app_update_status`].
474pub struct AppUpdateObserver {
475    id: u64,
476}
477
478impl Drop for AppUpdateObserver {
479    fn drop(&mut self) {
480        #[cfg(not(target_arch = "wasm32"))]
481        if let Ok(mut observers) = observers().lock() {
482            observers.retain(|(id, _)| *id != self.id);
483        }
484        #[cfg(target_arch = "wasm32")]
485        crate::thread_state::with(|state| state.app_update_observers.remove(self.id));
486    }
487}
488
489/// Observes update state changes. The current state is delivered immediately.
490#[cfg(not(target_arch = "wasm32"))]
491pub fn observe_app_update_status(
492    observer: impl Fn(AppUpdateStatus) + Send + Sync + 'static,
493) -> AppUpdateObserver {
494    let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
495    let observer: Observer = Arc::new(observer);
496    if let Ok(mut observers) = observers().lock() {
497        observers.push((id, Arc::clone(&observer)));
498    }
499    observer(app_update_status());
500    AppUpdateObserver { id }
501}
502
503/// Observes update state changes. The current state is delivered immediately.
504#[cfg(target_arch = "wasm32")]
505pub fn observe_app_update_status(
506    observer: impl Fn(AppUpdateStatus) + 'static,
507) -> AppUpdateObserver {
508    let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
509    let observer: std::rc::Rc<dyn Fn(AppUpdateStatus)> = std::rc::Rc::new(observer);
510    crate::thread_state::with(|state| {
511        state
512            .app_update_observers
513            .add(id, std::rc::Rc::clone(&observer));
514    });
515    observer(app_update_status());
516    AppUpdateObserver { id }
517}
518
519/// Publishes state from a platform updater.
520pub fn set_app_update_status(status: AppUpdateStatus) {
521    if let Ok(mut current) = status_slot().lock() {
522        if *current == status {
523            return;
524        }
525        *current = status.clone();
526    }
527    #[cfg(not(target_arch = "wasm32"))]
528    let observers = observers()
529        .lock()
530        .map(|observers| {
531            observers
532                .iter()
533                .map(|(_, observer)| Arc::clone(observer))
534                .collect::<Vec<_>>()
535        })
536        .unwrap_or_default();
537    #[cfg(target_arch = "wasm32")]
538    let observers = crate::thread_state::with(|state| state.app_update_observers.all());
539    for observer in observers {
540        observer(status.clone());
541    }
542}
543
544#[cfg(test)]
545#[path = "tests/app_update_tests.rs"]
546mod tests;