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