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