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 crate::registry::ServiceRegistry;
10use sha2::{Digest, Sha256};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, OnceLock};
13
14/// How a package's bytes are checked against what the release promised.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
16pub enum DigestAlgorithm {
17    /// SHA-256, which is what release feeds publish.
18    #[default]
19    Sha256,
20}
21
22impl DigestAlgorithm {
23    /// The name a release feed writes, and the name a platform's own digest API
24    /// answers to.
25    pub fn name(self) -> &'static str {
26        match self {
27            DigestAlgorithm::Sha256 => "sha256",
28        }
29    }
30
31    /// Reads an algorithm a release feed named, or `None` for one this
32    /// framework cannot compute — which is refused rather than skipped, because
33    /// a digest nobody checks is worse than no digest at all.
34    pub fn parse(name: &str) -> Option<Self> {
35        match name.trim().to_ascii_lowercase().as_str() {
36            "sha256" | "sha-256" => Some(DigestAlgorithm::Sha256),
37            _ => None,
38        }
39    }
40}
41
42/// The digest a downloaded package must match.
43#[derive(Clone, Debug, PartialEq, Eq, Hash)]
44pub struct PackageDigest {
45    pub algorithm: DigestAlgorithm,
46    /// Lower-case hexadecimal.
47    pub value: String,
48}
49
50impl PackageDigest {
51    /// A SHA-256 digest, from hexadecimal in either case.
52    pub fn sha256(value: impl AsRef<str>) -> Self {
53        Self {
54            algorithm: DigestAlgorithm::Sha256,
55            value: value.as_ref().trim().to_ascii_lowercase(),
56        }
57    }
58
59    /// Reads the `sha256:<hex>` form release feeds publish.
60    pub fn parse(value: &str) -> Option<Self> {
61        let (algorithm, digest) = value.split_once(':')?;
62        let algorithm = DigestAlgorithm::parse(algorithm)?;
63        let digest = digest.trim().to_ascii_lowercase();
64        (!digest.is_empty()).then_some(Self {
65            algorithm,
66            value: digest,
67        })
68    }
69
70    /// Whether this digest could be one: the right length, and hexadecimal.
71    ///
72    /// A malformed digest is refused before a download starts rather than after
73    /// it, so nobody waits for two hundred megabytes to learn the release feed
74    /// was misconfigured.
75    pub fn is_well_formed(&self) -> bool {
76        let expected = match self.algorithm {
77            DigestAlgorithm::Sha256 => 64,
78        };
79        self.value.len() == expected && self.value.bytes().all(|byte| byte.is_ascii_hexdigit())
80    }
81
82    /// The `sha256:<hex>` form.
83    pub fn to_feed_string(&self) -> String {
84        format!("{}:{}", self.algorithm.name(), self.value)
85    }
86}
87
88/// The SHA-256 of `bytes`, as lower-case hexadecimal.
89pub fn sha256_hex(bytes: &[u8]) -> String {
90    let mut hasher = Sha256::new();
91    hasher.update(bytes);
92    hex(&hasher.finalize())
93}
94
95fn hex(bytes: &[u8]) -> String {
96    let mut out = String::with_capacity(bytes.len() * 2);
97    for byte in bytes {
98        out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
99        out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
100    }
101    out
102}
103
104/// Checks a package as it is read, so a package too large to hold in memory is
105/// still checked.
106///
107/// An installer feeds every chunk it writes through this and calls
108/// [`DigestVerifier::finish`] before committing; nothing installs a package
109/// whose bytes were never seen in full.
110pub struct DigestVerifier {
111    expected: PackageDigest,
112    hasher: Sha256,
113    len: u64,
114}
115
116impl DigestVerifier {
117    /// A verifier for `expected`, or an error when the digest is malformed.
118    pub fn new(expected: PackageDigest) -> Result<Self, AppUpdateError> {
119        if !expected.is_well_formed() {
120            return Err(AppUpdateError::MalformedDigest(expected.to_feed_string()));
121        }
122        Ok(Self {
123            expected,
124            hasher: Sha256::new(),
125            len: 0,
126        })
127    }
128
129    /// Feeds the next chunk of the package.
130    pub fn update(&mut self, chunk: &[u8]) {
131        self.hasher.update(chunk);
132        self.len += chunk.len() as u64;
133    }
134
135    /// How many bytes have been read so far.
136    pub fn len(&self) -> u64 {
137        self.len
138    }
139
140    /// Whether nothing has been read yet.
141    pub fn is_empty(&self) -> bool {
142        self.len == 0
143    }
144
145    /// Checks what was read against what was promised.
146    pub fn finish(self) -> Result<(), AppUpdateError> {
147        let actual = hex(&self.hasher.finalize());
148        if actual == self.expected.value {
149            Ok(())
150        } else {
151            Err(AppUpdateError::VerificationFailed {
152                expected: self.expected.value,
153                actual,
154            })
155        }
156    }
157}
158
159/// Checks a package held in memory against `digest`.
160pub fn verify_package(bytes: &[u8], digest: &PackageDigest) -> Result<(), AppUpdateError> {
161    let mut verifier = DigestVerifier::new(digest.clone())?;
162    verifier.update(bytes);
163    verifier.finish()
164}
165
166/// A package an update would install.
167#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
168pub struct UpdatePackage {
169    /// The version this package installs.
170    pub version: String,
171    /// Where the package is downloaded from.
172    pub download_url: String,
173    /// How large it is, when the release feed says.
174    pub size: Option<u64>,
175    /// What its bytes must hash to.
176    ///
177    /// `None` means the release feed published none, and
178    /// [`install_app_update`] refuses such a package: the platform's own
179    /// signature check catches a package signed by someone else, but not one
180    /// that arrived corrupted, and this is the one download that replaces the
181    /// application. A feed with no digest is a feed to fix.
182    pub digest: Option<PackageDigest>,
183    /// Release notes, when the feed carries them.
184    pub notes: Option<String>,
185}
186
187impl UpdatePackage {
188    /// A package at `download_url` installing `version`.
189    pub fn new(version: impl Into<String>, download_url: impl Into<String>) -> Self {
190        Self {
191            version: version.into(),
192            download_url: download_url.into(),
193            ..Self::default()
194        }
195    }
196
197    pub fn with_size(mut self, size: u64) -> Self {
198        self.size = Some(size);
199        self
200    }
201
202    pub fn with_digest(mut self, digest: PackageDigest) -> Self {
203        self.digest = Some(digest);
204        self
205    }
206
207    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
208        self.notes = Some(notes.into());
209        self
210    }
211
212    /// Whether this package can be checked against what the feed promised.
213    pub fn is_verifiable(&self) -> bool {
214        self.digest
215            .as_ref()
216            .is_some_and(PackageDigest::is_well_formed)
217    }
218}
219
220/// A GitHub release feed used to discover an application package.
221#[derive(Clone, Debug, PartialEq, Eq, Hash)]
222pub struct GitHubReleaseUpdate {
223    /// Repository in `owner/name` form.
224    pub repository: String,
225    /// Version of the running application.
226    pub current_version: String,
227    /// File-name suffix selected from the release assets, such as `.apk`.
228    pub asset_suffix: String,
229}
230
231impl GitHubReleaseUpdate {
232    /// Creates a GitHub release request.
233    pub fn new(
234        repository: impl Into<String>,
235        current_version: impl Into<String>,
236        asset_suffix: impl Into<String>,
237    ) -> Self {
238        Self {
239            repository: repository.into(),
240            current_version: current_version.into(),
241            asset_suffix: asset_suffix.into(),
242        }
243    }
244}
245
246/// Observable state of the application update flow.
247#[derive(Clone, Debug, Default, PartialEq, Eq)]
248pub enum AppUpdateStatus {
249    /// No operation has started.
250    #[default]
251    Idle,
252    /// The release feed is being queried.
253    Checking,
254    /// The running application is current.
255    UpToDate,
256    /// A package can be installed.
257    Available {
258        /// What the release feed offers, including its size and digest when it
259        /// published them.
260        package: UpdatePackage,
261    },
262    /// A package is being transferred to the platform installer.
263    Downloading {
264        /// Bytes transferred so far.
265        downloaded: u64,
266        /// Total bytes when supplied by the server.
267        total: Option<u64>,
268    },
269    /// The transfer finished and the package is being checked against the
270    /// digest the release feed published.
271    Verifying,
272    /// The platform is asking the user to approve installation.
273    AwaitingConfirmation,
274    /// The platform installer accepted the package.
275    Installing,
276    /// The operation could not continue.
277    Error(String),
278}
279
280/// Failure to start an update operation.
281#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
282pub enum AppUpdateError {
283    /// This platform has no registered installer.
284    #[error("application updates are unavailable on this platform")]
285    Unsupported,
286    /// The platform rejected the request before work started.
287    #[error("application update request failed: {0}")]
288    Request(String),
289    /// The release feed published no digest for this package.
290    ///
291    /// Refused rather than installed: this is an application replacing itself
292    /// with bytes off the network, and bytes nobody checked are bytes nobody
293    /// checked whether or not the feed mentioned it. A feed that publishes no
294    /// digest is a feed to fix, not a check to skip.
295    #[error("the release feed published no digest for this package, so it cannot be checked")]
296    Unverifiable,
297    /// The release feed published a digest this framework cannot check.
298    ///
299    /// Refused rather than ignored: a digest nobody checks reads as a package
300    /// that was verified.
301    #[error("the release feed published a digest that cannot be checked: {0}")]
302    MalformedDigest(String),
303    /// What arrived is not what the release feed promised.
304    #[error(
305        "the downloaded package does not match its digest (expected {expected}, got {actual})"
306    )]
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
398/// Publishes a failure as the update status and hands it back.
399///
400/// Every way these two entry points can fail goes through here, so the
401/// observable status is the whole story: an application that only observes
402/// [`app_update_status`] sees a host that cannot check just as it sees a
403/// download that failed. Without this, the failures that never reach a
404/// backend — no updater registered, or one that does not claim this half —
405/// would return an error into a caller that has nowhere to put it, and every
406/// application would mirror the `Result` into the status by hand.
407fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
408    set_app_update_status(AppUpdateStatus::Error(error.to_string()));
409    Err(error)
410}
411
412/// Starts update discovery and publishes the initial state.
413pub fn check_for_app_update(source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
414    let Some(updater) = PLATFORM_UPDATER.get() else {
415        return publish_failure(AppUpdateError::Unsupported);
416    };
417    if !updater.capabilities().check {
418        return publish_failure(AppUpdateError::Unsupported);
419    }
420    set_app_update_status(AppUpdateStatus::Checking);
421    updater.check(source).inspect_err(|error| {
422        set_app_update_status(AppUpdateStatus::Error(error.to_string()));
423    })
424}
425
426/// Starts package installation and publishes the initial transfer state.
427///
428/// A digest the release feed published but this framework cannot check is
429/// refused here, before anything is downloaded: nobody waits for two hundred
430/// megabytes to learn the feed was misconfigured, and nothing reaches an
431/// installer unchecked because its digest was unreadable.
432pub fn install_app_update(package: &UpdatePackage) -> Result<(), AppUpdateError> {
433    let Some(updater) = PLATFORM_UPDATER.get() else {
434        return publish_failure(AppUpdateError::Unsupported);
435    };
436    if !updater.capabilities().install {
437        return publish_failure(AppUpdateError::Unsupported);
438    }
439    let error = match &package.digest {
440        None => Some(AppUpdateError::Unverifiable),
441        Some(digest) if !digest.is_well_formed() => {
442            Some(AppUpdateError::MalformedDigest(digest.to_feed_string()))
443        }
444        Some(_) => None,
445    };
446    if let Some(error) = error {
447        return publish_failure(error);
448    }
449    set_app_update_status(AppUpdateStatus::Downloading {
450        downloaded: 0,
451        total: package.size,
452    });
453    updater.install(package).inspect_err(|error| {
454        set_app_update_status(AppUpdateStatus::Error(error.to_string()));
455    })
456}
457
458fn status_slot() -> &'static Mutex<AppUpdateStatus> {
459    static STATUS: OnceLock<Mutex<AppUpdateStatus>> = OnceLock::new();
460    STATUS.get_or_init(|| Mutex::new(AppUpdateStatus::Idle))
461}
462
463/// Returns the latest update state.
464pub fn app_update_status() -> AppUpdateStatus {
465    status_slot()
466        .lock()
467        .map(|status| status.clone())
468        .unwrap_or_else(|poisoned| poisoned.into_inner().clone())
469}
470
471#[cfg(not(target_arch = "wasm32"))]
472type Observer = Arc<dyn Fn(AppUpdateStatus) + Send + Sync>;
473#[cfg(target_arch = "wasm32")]
474type Observer = std::rc::Rc<dyn Fn(AppUpdateStatus)>;
475
476#[cfg(not(target_arch = "wasm32"))]
477fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
478    static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
479    OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
480}
481
482#[cfg(target_arch = "wasm32")]
483thread_local! {
484    static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
485}
486
487static NEXT_OBSERVER_ID: AtomicU64 = AtomicU64::new(1);
488
489/// Registration returned by [`observe_app_update_status`].
490pub struct AppUpdateObserver {
491    id: u64,
492}
493
494impl Drop for AppUpdateObserver {
495    fn drop(&mut self) {
496        #[cfg(not(target_arch = "wasm32"))]
497        if let Ok(mut observers) = observers().lock() {
498            observers.retain(|(id, _)| *id != self.id);
499        }
500        #[cfg(target_arch = "wasm32")]
501        OBSERVERS.with(|observers| observers.borrow_mut().retain(|(id, _)| *id != self.id));
502    }
503}
504
505/// Observes update state changes. The current state is delivered immediately.
506#[cfg(not(target_arch = "wasm32"))]
507pub fn observe_app_update_status(
508    observer: impl Fn(AppUpdateStatus) + Send + Sync + 'static,
509) -> AppUpdateObserver {
510    let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
511    let observer: Observer = Arc::new(observer);
512    if let Ok(mut observers) = observers().lock() {
513        observers.push((id, Arc::clone(&observer)));
514    }
515    observer(app_update_status());
516    AppUpdateObserver { id }
517}
518
519/// Observes update state changes. The current state is delivered immediately.
520#[cfg(target_arch = "wasm32")]
521pub fn observe_app_update_status(
522    observer: impl Fn(AppUpdateStatus) + 'static,
523) -> AppUpdateObserver {
524    let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
525    let observer: Observer = std::rc::Rc::new(observer);
526    OBSERVERS.with(|observers| {
527        observers
528            .borrow_mut()
529            .push((id, std::rc::Rc::clone(&observer)))
530    });
531    observer(app_update_status());
532    AppUpdateObserver { id }
533}
534
535/// Publishes state from a platform updater.
536pub fn set_app_update_status(status: AppUpdateStatus) {
537    if let Ok(mut current) = status_slot().lock() {
538        if *current == status {
539            return;
540        }
541        *current = status.clone();
542    }
543    #[cfg(not(target_arch = "wasm32"))]
544    let observers = observers()
545        .lock()
546        .map(|observers| {
547            observers
548                .iter()
549                .map(|(_, observer)| Arc::clone(observer))
550                .collect::<Vec<_>>()
551        })
552        .unwrap_or_default();
553    #[cfg(target_arch = "wasm32")]
554    let observers = OBSERVERS.with(|observers| {
555        observers
556            .borrow()
557            .iter()
558            .map(|(_, observer)| std::rc::Rc::clone(observer))
559            .collect::<Vec<_>>()
560    });
561    for observer in observers {
562        observer(status.clone());
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use std::sync::atomic::AtomicUsize;
570
571    struct RecordingUpdater {
572        checks: AtomicUsize,
573        installed: Mutex<Vec<UpdatePackage>>,
574    }
575
576    impl RecordingUpdater {
577        fn new() -> Self {
578            Self {
579                checks: AtomicUsize::new(0),
580                installed: Mutex::new(Vec::new()),
581            }
582        }
583
584        fn installs(&self) -> Vec<UpdatePackage> {
585            self.installed
586                .lock()
587                .unwrap_or_else(|error| error.into_inner())
588                .clone()
589        }
590    }
591
592    impl AppUpdater for RecordingUpdater {
593        fn capabilities(&self) -> AppUpdateCapabilities {
594            AppUpdateCapabilities {
595                check: true,
596                install: true,
597            }
598        }
599
600        fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
601            self.checks.fetch_add(1, Ordering::Relaxed);
602            Ok(())
603        }
604
605        fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
606            self.installed
607                .lock()
608                .unwrap_or_else(|error| error.into_inner())
609                .push(package.clone());
610            Ok(())
611        }
612    }
613
614    /// The digest of the empty input, which is the one value every SHA-256
615    /// implementation agrees on and the one a broken wiring gets wrong.
616    const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
617    const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
618
619    #[test]
620    fn the_digest_is_the_one_every_other_implementation_computes() {
621        assert_eq!(sha256_hex(b""), EMPTY_SHA256);
622        assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
623    }
624
625    #[test]
626    fn a_feed_digest_is_read_in_the_form_feeds_publish_it() {
627        let digest = PackageDigest::parse(&format!("sha256:{}", ABC_SHA256.to_uppercase()))
628            .expect("a sha256 digest");
629        assert_eq!(digest.algorithm, DigestAlgorithm::Sha256);
630        assert_eq!(digest.value, ABC_SHA256, "case is normalised on the way in");
631        assert_eq!(digest.to_feed_string(), format!("sha256:{ABC_SHA256}"));
632        assert!(digest.is_well_formed());
633    }
634
635    #[test]
636    fn a_digest_this_framework_cannot_check_is_refused_rather_than_ignored() {
637        assert_eq!(PackageDigest::parse("md5:abcdef"), None);
638        assert_eq!(PackageDigest::parse("sha256:"), None);
639        assert_eq!(PackageDigest::parse("no-algorithm"), None);
640        assert!(!PackageDigest::sha256("not hexadecimal").is_well_formed());
641        assert!(!PackageDigest::sha256("abcd").is_well_formed(), "too short");
642        assert!(matches!(
643            DigestVerifier::new(PackageDigest::sha256("abcd")),
644            Err(AppUpdateError::MalformedDigest(_))
645        ));
646    }
647
648    #[test]
649    fn a_package_that_matches_its_digest_verifies() {
650        assert_eq!(
651            verify_package(b"abc", &PackageDigest::sha256(ABC_SHA256)),
652            Ok(())
653        );
654    }
655
656    #[test]
657    fn a_package_that_does_not_match_reports_both_digests() {
658        let error = verify_package(b"abd", &PackageDigest::sha256(ABC_SHA256))
659            .expect_err("a changed byte must not verify");
660        match error {
661            AppUpdateError::VerificationFailed { expected, actual } => {
662                assert_eq!(expected, ABC_SHA256);
663                assert_ne!(actual, ABC_SHA256);
664                assert_eq!(actual, sha256_hex(b"abd"));
665            }
666            other => panic!("expected a verification failure, got {other}"),
667        }
668    }
669
670    /// A package can be larger than memory, so it is checked as it is read.
671    #[test]
672    fn a_package_read_in_chunks_verifies_the_same_as_one_read_whole() {
673        let mut verifier =
674            DigestVerifier::new(PackageDigest::sha256(ABC_SHA256)).expect("a well-formed digest");
675        assert!(verifier.is_empty());
676        verifier.update(b"a");
677        verifier.update(b"b");
678        verifier.update(b"c");
679        assert_eq!(verifier.len(), 3);
680        assert_eq!(verifier.finish(), Ok(()));
681    }
682
683    #[test]
684    fn a_package_carries_what_the_feed_promised_about_it() {
685        let package = UpdatePackage::new("1.2.3", "https://example.test/app.apk")
686            .with_size(4096)
687            .with_digest(PackageDigest::sha256(ABC_SHA256))
688            .with_notes("Fixes the thing");
689        assert_eq!(package.version, "1.2.3");
690        assert_eq!(package.size, Some(4096));
691        assert!(package.is_verifiable());
692        assert_eq!(package.notes.as_deref(), Some("Fixes the thing"));
693
694        assert!(
695            !UpdatePackage::new("1.2.3", "https://example.test/app.apk").is_verifiable(),
696            "a feed that published no digest leaves nothing to check against"
697        );
698    }
699
700    #[test]
701    fn request_builds_typed_source() {
702        let source = GitHubReleaseUpdate::new("owner/app", "1.2.3", ".apk");
703        assert_eq!(source.repository, "owner/app");
704        assert_eq!(source.current_version, "1.2.3");
705        assert_eq!(source.asset_suffix, ".apk");
706    }
707
708    #[test]
709    fn operations_publish_and_forward() {
710        let _guard = crate::registry::test_service_guard();
711        let updater = Arc::new(RecordingUpdater::new());
712        set_platform_app_updater(updater.clone());
713        assert!(app_updates_supported());
714        check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")).unwrap();
715        assert_eq!(updater.checks.load(Ordering::Relaxed), 1);
716        assert_eq!(app_update_status(), AppUpdateStatus::Checking);
717
718        let package = UpdatePackage::new("2", "https://example.test/app.apk")
719            .with_size(4096)
720            .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
721        install_app_update(&package).unwrap();
722        assert_eq!(updater.installs(), vec![package]);
723        assert_eq!(
724            app_update_status(),
725            AppUpdateStatus::Downloading {
726                downloaded: 0,
727                total: Some(4096)
728            },
729            "the size the feed published is reported before the first byte arrives"
730        );
731        clear_platform_app_updater();
732        assert!(!app_updates_supported());
733    }
734
735    /// Nobody waits for two hundred megabytes to learn the release feed was
736    /// misconfigured, and nothing reaches an installer unchecked because its
737    /// digest could not be read.
738    #[test]
739    fn a_package_with_an_uncheckable_digest_is_refused_before_it_is_downloaded() {
740        let _guard = crate::registry::test_service_guard();
741        let updater = Arc::new(RecordingUpdater::new());
742        set_platform_app_updater(updater.clone());
743        let package = UpdatePackage::new("2", "https://example.test/app.apk")
744            .with_digest(PackageDigest::sha256("not-a-digest"));
745        assert!(matches!(
746            install_app_update(&package),
747            Err(AppUpdateError::MalformedDigest(_))
748        ));
749        assert!(updater.installs().is_empty());
750        assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
751        clear_platform_app_updater();
752    }
753
754    /// The defect a consumer application found: a host that cannot check or
755    /// install returned an error and published nothing, so a screen observing
756    /// the update status showed the state it was already in. Every failure
757    /// reaches the status, or an application has to mirror the `Result` into
758    /// it by hand — which is what the framework owning this is meant to stop.
759    #[test]
760    fn a_host_that_cannot_update_says_so_through_the_status_and_not_only_the_result() {
761        let _guard = crate::registry::test_service_guard();
762
763        // No backend at all.
764        clear_platform_app_updater();
765        set_app_update_status(AppUpdateStatus::Idle);
766        assert_eq!(
767            check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")),
768            Err(AppUpdateError::Unsupported)
769        );
770        assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
771
772        // A backend that discovers releases but cannot install one, which is
773        // every desktop and iOS host.
774        struct CheckOnlyUpdater;
775        impl AppUpdater for CheckOnlyUpdater {
776            fn capabilities(&self) -> AppUpdateCapabilities {
777                AppUpdateCapabilities {
778                    check: true,
779                    install: false,
780                }
781            }
782            fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
783                Ok(())
784            }
785        }
786        set_platform_app_updater(Arc::new(CheckOnlyUpdater));
787        set_app_update_status(AppUpdateStatus::Idle);
788        let package = UpdatePackage::new("2", "https://example.test/app.apk")
789            .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
790        assert_eq!(
791            install_app_update(&package),
792            Err(AppUpdateError::Unsupported)
793        );
794        assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
795        assert!(app_update_checks_supported());
796        assert!(!app_updates_supported());
797        clear_platform_app_updater();
798    }
799
800    /// An application replacing itself with bytes off the network is the one
801    /// download that must not be taken on trust.
802    #[test]
803    fn a_package_with_no_digest_at_all_never_reaches_the_installer() {
804        let _guard = crate::registry::test_service_guard();
805        let updater = Arc::new(RecordingUpdater::new());
806        set_platform_app_updater(updater.clone());
807        let package = UpdatePackage::new("2", "https://example.test/app.apk");
808
809        assert!(!package.is_verifiable());
810        assert_eq!(
811            install_app_update(&package),
812            Err(AppUpdateError::Unverifiable)
813        );
814
815        assert!(updater.installs().is_empty());
816        assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
817        clear_platform_app_updater();
818    }
819
820    #[test]
821    fn observer_receives_current_and_changed_status() {
822        let _guard = crate::registry::test_service_guard();
823        set_app_update_status(AppUpdateStatus::Idle);
824        let seen = Arc::new(Mutex::new(Vec::new()));
825        let captured = Arc::clone(&seen);
826        let observer = observe_app_update_status(move |status| {
827            captured
828                .lock()
829                .unwrap_or_else(|error| error.into_inner())
830                .push(status);
831        });
832        set_app_update_status(AppUpdateStatus::Verifying);
833        set_app_update_status(AppUpdateStatus::Installing);
834        assert_eq!(
835            *seen.lock().unwrap_or_else(|error| error.into_inner()),
836            vec![
837                AppUpdateStatus::Idle,
838                AppUpdateStatus::Verifying,
839                AppUpdateStatus::Installing
840            ]
841        );
842        drop(observer);
843        set_app_update_status(AppUpdateStatus::Idle);
844    }
845}