1use std::sync::{
10 Arc, Mutex, OnceLock,
11 atomic::{AtomicU64, Ordering},
12};
13
14use sha2::{Digest, Sha256};
15
16use crate::registry::ServiceRegistry;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
20pub enum DigestAlgorithm {
21 #[default]
23 Sha256,
24}
25
26impl DigestAlgorithm {
27 pub fn name(self) -> &'static str {
30 match self {
31 DigestAlgorithm::Sha256 => "sha256",
32 }
33 }
34
35 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
48pub struct PackageDigest {
49 pub algorithm: DigestAlgorithm,
50 pub value: String,
52}
53
54impl PackageDigest {
55 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 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 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 pub fn to_feed_string(&self) -> String {
88 format!("{}:{}", self.algorithm.name(), self.value)
89 }
90}
91
92pub 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
108pub struct DigestVerifier {
115 expected: PackageDigest,
116 hasher: Sha256,
117 len: u64,
118}
119
120impl DigestVerifier {
121 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 pub fn update(&mut self, chunk: &[u8]) {
135 self.hasher.update(chunk);
136 self.len += chunk.len() as u64;
137 }
138
139 pub fn len(&self) -> u64 {
141 self.len
142 }
143
144 pub fn is_empty(&self) -> bool {
146 self.len == 0
147 }
148
149 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
163pub 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
172pub struct UpdatePackage {
173 pub version: String,
175 pub download_url: String,
177 pub size: Option<u64>,
179 pub digest: Option<PackageDigest>,
187 pub notes: Option<String>,
189}
190
191impl UpdatePackage {
192 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 pub fn is_verifiable(&self) -> bool {
218 self.digest
219 .as_ref()
220 .is_some_and(PackageDigest::is_well_formed)
221 }
222}
223
224#[derive(Clone, Debug, PartialEq, Eq, Hash)]
226pub struct GitHubReleaseUpdate {
227 pub repository: String,
229 pub current_version: String,
231 pub asset_suffix: String,
233}
234
235impl GitHubReleaseUpdate {
236 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
252pub enum AppUpdateStatus {
253 #[default]
255 Idle,
256 Checking,
258 UpToDate,
260 Available {
262 package: UpdatePackage,
265 },
266 Downloading {
268 downloaded: u64,
270 total: Option<u64>,
272 },
273 Verifying,
276 AwaitingConfirmation,
278 Installing,
280 Error(String),
282}
283
284#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
286pub enum AppUpdateError {
287 #[error("application updates are unavailable on this platform")]
289 Unsupported,
290 #[error("application update request failed: {0}")]
292 Request(String),
293 #[error("the release feed published no digest for this package, so it cannot be checked")]
300 Unverifiable,
301 #[error("the release feed published a digest that cannot be checked: {0}")]
306 MalformedDigest(String),
307 #[error("the downloaded package does not match its digest (expected {expected}, got {actual})")]
309 VerificationFailed {
310 expected: String,
312 actual: String,
314 },
315}
316
317pub trait AppUpdater: Send + Sync {
319 fn capabilities(&self) -> AppUpdateCapabilities {
326 AppUpdateCapabilities::default()
327 }
328
329 fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
332 let _ = source;
333 Err(AppUpdateError::Unsupported)
334 }
335
336 fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
342 let _ = package;
343 Err(AppUpdateError::Unsupported)
344 }
345}
346
347#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
356pub struct AppUpdateCapabilities {
357 pub check: bool,
359 pub install: bool,
361}
362
363pub type AppUpdaterRef = Arc<dyn AppUpdater>;
365
366static PLATFORM_UPDATER: ServiceRegistry<dyn AppUpdater> = ServiceRegistry::new();
367
368pub fn set_platform_app_updater(updater: AppUpdaterRef) {
370 PLATFORM_UPDATER.set(updater);
371}
372
373pub fn clear_platform_app_updater() {
375 PLATFORM_UPDATER.clear();
376}
377
378pub fn app_update_capabilities() -> AppUpdateCapabilities {
380 PLATFORM_UPDATER
381 .get()
382 .map(|updater| updater.capabilities())
383 .unwrap_or_default()
384}
385
386pub fn app_updates_supported() -> bool {
388 app_update_capabilities().install
389}
390
391pub fn app_update_checks_supported() -> bool {
397 app_update_capabilities().check
398}
399
400fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
410 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
411 Err(error)
412}
413
414pub fn check_for_app_update(source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
416 let Some(updater) = PLATFORM_UPDATER.get() else {
417 return publish_failure(AppUpdateError::Unsupported);
418 };
419 if !updater.capabilities().check {
420 return publish_failure(AppUpdateError::Unsupported);
421 }
422 set_app_update_status(AppUpdateStatus::Checking);
423 updater.check(source).inspect_err(|error| {
424 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
425 })
426}
427
428pub fn install_app_update(package: &UpdatePackage) -> Result<(), AppUpdateError> {
435 let Some(updater) = PLATFORM_UPDATER.get() else {
436 return publish_failure(AppUpdateError::Unsupported);
437 };
438 if !updater.capabilities().install {
439 return publish_failure(AppUpdateError::Unsupported);
440 }
441 let error = match &package.digest {
442 None => Some(AppUpdateError::Unverifiable),
443 Some(digest) if !digest.is_well_formed() => {
444 Some(AppUpdateError::MalformedDigest(digest.to_feed_string()))
445 }
446 Some(_) => None,
447 };
448 if let Some(error) = error {
449 return publish_failure(error);
450 }
451 set_app_update_status(AppUpdateStatus::Downloading {
452 downloaded: 0,
453 total: package.size,
454 });
455 updater.install(package).inspect_err(|error| {
456 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
457 })
458}
459
460fn status_slot() -> &'static Mutex<AppUpdateStatus> {
461 static STATUS: OnceLock<Mutex<AppUpdateStatus>> = OnceLock::new();
462 STATUS.get_or_init(|| Mutex::new(AppUpdateStatus::Idle))
463}
464
465pub fn app_update_status() -> AppUpdateStatus {
467 status_slot()
468 .lock()
469 .map(|status| status.clone())
470 .unwrap_or_else(|poisoned| poisoned.into_inner().clone())
471}
472
473#[cfg(not(target_arch = "wasm32"))]
474type Observer = Arc<dyn Fn(AppUpdateStatus) + Send + Sync>;
475#[cfg(target_arch = "wasm32")]
476type Observer = std::rc::Rc<dyn Fn(AppUpdateStatus)>;
477
478#[cfg(not(target_arch = "wasm32"))]
479fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
480 static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
481 OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
482}
483
484#[cfg(target_arch = "wasm32")]
485thread_local! {
486 static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
487}
488
489static NEXT_OBSERVER_ID: AtomicU64 = AtomicU64::new(1);
490
491pub struct AppUpdateObserver {
493 id: u64,
494}
495
496impl Drop for AppUpdateObserver {
497 fn drop(&mut self) {
498 #[cfg(not(target_arch = "wasm32"))]
499 if let Ok(mut observers) = observers().lock() {
500 observers.retain(|(id, _)| *id != self.id);
501 }
502 #[cfg(target_arch = "wasm32")]
503 OBSERVERS.with(|observers| observers.borrow_mut().retain(|(id, _)| *id != self.id));
504 }
505}
506
507#[cfg(not(target_arch = "wasm32"))]
509pub fn observe_app_update_status(
510 observer: impl Fn(AppUpdateStatus) + Send + Sync + 'static,
511) -> AppUpdateObserver {
512 let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
513 let observer: Observer = Arc::new(observer);
514 if let Ok(mut observers) = observers().lock() {
515 observers.push((id, Arc::clone(&observer)));
516 }
517 observer(app_update_status());
518 AppUpdateObserver { id }
519}
520
521#[cfg(target_arch = "wasm32")]
523pub fn observe_app_update_status(
524 observer: impl Fn(AppUpdateStatus) + 'static,
525) -> AppUpdateObserver {
526 let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
527 let observer: Observer = std::rc::Rc::new(observer);
528 OBSERVERS.with(|observers| {
529 observers
530 .borrow_mut()
531 .push((id, std::rc::Rc::clone(&observer)))
532 });
533 observer(app_update_status());
534 AppUpdateObserver { id }
535}
536
537pub fn set_app_update_status(status: AppUpdateStatus) {
539 if let Ok(mut current) = status_slot().lock() {
540 if *current == status {
541 return;
542 }
543 *current = status.clone();
544 }
545 #[cfg(not(target_arch = "wasm32"))]
546 let observers = observers()
547 .lock()
548 .map(|observers| {
549 observers
550 .iter()
551 .map(|(_, observer)| Arc::clone(observer))
552 .collect::<Vec<_>>()
553 })
554 .unwrap_or_default();
555 #[cfg(target_arch = "wasm32")]
556 let observers = OBSERVERS.with(|observers| {
557 observers
558 .borrow()
559 .iter()
560 .map(|(_, observer)| std::rc::Rc::clone(observer))
561 .collect::<Vec<_>>()
562 });
563 for observer in observers {
564 observer(status.clone());
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use std::sync::atomic::AtomicUsize;
571
572 use super::*;
573
574 struct RecordingUpdater {
575 checks: AtomicUsize,
576 installed: Mutex<Vec<UpdatePackage>>,
577 }
578
579 impl RecordingUpdater {
580 fn new() -> Self {
581 Self {
582 checks: AtomicUsize::new(0),
583 installed: Mutex::new(Vec::new()),
584 }
585 }
586
587 fn installs(&self) -> Vec<UpdatePackage> {
588 self.installed
589 .lock()
590 .unwrap_or_else(|error| error.into_inner())
591 .clone()
592 }
593 }
594
595 impl AppUpdater for RecordingUpdater {
596 fn capabilities(&self) -> AppUpdateCapabilities {
597 AppUpdateCapabilities {
598 check: true,
599 install: true,
600 }
601 }
602
603 fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
604 self.checks.fetch_add(1, Ordering::Relaxed);
605 Ok(())
606 }
607
608 fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
609 self.installed
610 .lock()
611 .unwrap_or_else(|error| error.into_inner())
612 .push(package.clone());
613 Ok(())
614 }
615 }
616
617 const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
620 const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
621
622 #[test]
623 fn the_digest_is_the_one_every_other_implementation_computes() {
624 assert_eq!(sha256_hex(b""), EMPTY_SHA256);
625 assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
626 }
627
628 #[test]
629 fn a_feed_digest_is_read_in_the_form_feeds_publish_it() {
630 let digest = PackageDigest::parse(&format!("sha256:{}", ABC_SHA256.to_uppercase()))
631 .expect("a sha256 digest");
632 assert_eq!(digest.algorithm, DigestAlgorithm::Sha256);
633 assert_eq!(digest.value, ABC_SHA256, "case is normalised on the way in");
634 assert_eq!(digest.to_feed_string(), format!("sha256:{ABC_SHA256}"));
635 assert!(digest.is_well_formed());
636 }
637
638 #[test]
639 fn a_digest_this_framework_cannot_check_is_refused_rather_than_ignored() {
640 assert_eq!(PackageDigest::parse("md5:abcdef"), None);
641 assert_eq!(PackageDigest::parse("sha256:"), None);
642 assert_eq!(PackageDigest::parse("no-algorithm"), None);
643 assert!(!PackageDigest::sha256("not hexadecimal").is_well_formed());
644 assert!(!PackageDigest::sha256("abcd").is_well_formed(), "too short");
645 assert!(matches!(
646 DigestVerifier::new(PackageDigest::sha256("abcd")),
647 Err(AppUpdateError::MalformedDigest(_))
648 ));
649 }
650
651 #[test]
652 fn a_package_that_matches_its_digest_verifies() {
653 assert_eq!(
654 verify_package(b"abc", &PackageDigest::sha256(ABC_SHA256)),
655 Ok(())
656 );
657 }
658
659 #[test]
660 fn a_package_that_does_not_match_reports_both_digests() {
661 let error = verify_package(b"abd", &PackageDigest::sha256(ABC_SHA256))
662 .expect_err("a changed byte must not verify");
663 match error {
664 AppUpdateError::VerificationFailed { expected, actual } => {
665 assert_eq!(expected, ABC_SHA256);
666 assert_ne!(actual, ABC_SHA256);
667 assert_eq!(actual, sha256_hex(b"abd"));
668 }
669 other => panic!("expected a verification failure, got {other}"),
670 }
671 }
672
673 #[test]
675 fn a_package_read_in_chunks_verifies_the_same_as_one_read_whole() {
676 let mut verifier =
677 DigestVerifier::new(PackageDigest::sha256(ABC_SHA256)).expect("a well-formed digest");
678 assert!(verifier.is_empty());
679 verifier.update(b"a");
680 verifier.update(b"b");
681 verifier.update(b"c");
682 assert_eq!(verifier.len(), 3);
683 assert_eq!(verifier.finish(), Ok(()));
684 }
685
686 #[test]
687 fn a_package_carries_what_the_feed_promised_about_it() {
688 let package = UpdatePackage::new("1.2.3", "https://example.test/app.apk")
689 .with_size(4096)
690 .with_digest(PackageDigest::sha256(ABC_SHA256))
691 .with_notes("Fixes the thing");
692 assert_eq!(package.version, "1.2.3");
693 assert_eq!(package.size, Some(4096));
694 assert!(package.is_verifiable());
695 assert_eq!(package.notes.as_deref(), Some("Fixes the thing"));
696
697 assert!(
698 !UpdatePackage::new("1.2.3", "https://example.test/app.apk").is_verifiable(),
699 "a feed that published no digest leaves nothing to check against"
700 );
701 }
702
703 #[test]
704 fn request_builds_typed_source() {
705 let source = GitHubReleaseUpdate::new("owner/app", "1.2.3", ".apk");
706 assert_eq!(source.repository, "owner/app");
707 assert_eq!(source.current_version, "1.2.3");
708 assert_eq!(source.asset_suffix, ".apk");
709 }
710
711 #[test]
712 fn operations_publish_and_forward() {
713 let _guard = crate::registry::test_service_guard();
714 let updater = Arc::new(RecordingUpdater::new());
715 set_platform_app_updater(updater.clone());
716 assert!(app_updates_supported());
717 check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")).unwrap();
718 assert_eq!(updater.checks.load(Ordering::Relaxed), 1);
719 assert_eq!(app_update_status(), AppUpdateStatus::Checking);
720
721 let package = UpdatePackage::new("2", "https://example.test/app.apk")
722 .with_size(4096)
723 .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
724 install_app_update(&package).unwrap();
725 assert_eq!(updater.installs(), vec![package]);
726 assert_eq!(
727 app_update_status(),
728 AppUpdateStatus::Downloading {
729 downloaded: 0,
730 total: Some(4096)
731 },
732 "the size the feed published is reported before the first byte arrives"
733 );
734 clear_platform_app_updater();
735 assert!(!app_updates_supported());
736 }
737
738 #[test]
742 fn a_package_with_an_uncheckable_digest_is_refused_before_it_is_downloaded() {
743 let _guard = crate::registry::test_service_guard();
744 let updater = Arc::new(RecordingUpdater::new());
745 set_platform_app_updater(updater.clone());
746 let package = UpdatePackage::new("2", "https://example.test/app.apk")
747 .with_digest(PackageDigest::sha256("not-a-digest"));
748 assert!(matches!(
749 install_app_update(&package),
750 Err(AppUpdateError::MalformedDigest(_))
751 ));
752 assert!(updater.installs().is_empty());
753 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
754 clear_platform_app_updater();
755 }
756
757 #[test]
763 fn a_host_that_cannot_update_says_so_through_the_status_and_not_only_the_result() {
764 let _guard = crate::registry::test_service_guard();
765
766 clear_platform_app_updater();
768 set_app_update_status(AppUpdateStatus::Idle);
769 assert_eq!(
770 check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")),
771 Err(AppUpdateError::Unsupported)
772 );
773 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
774
775 struct CheckOnlyUpdater;
778 impl AppUpdater for CheckOnlyUpdater {
779 fn capabilities(&self) -> AppUpdateCapabilities {
780 AppUpdateCapabilities {
781 check: true,
782 install: false,
783 }
784 }
785 fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
786 Ok(())
787 }
788 }
789 set_platform_app_updater(Arc::new(CheckOnlyUpdater));
790 set_app_update_status(AppUpdateStatus::Idle);
791 let package = UpdatePackage::new("2", "https://example.test/app.apk")
792 .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
793 assert_eq!(
794 install_app_update(&package),
795 Err(AppUpdateError::Unsupported)
796 );
797 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
798 assert!(app_update_checks_supported());
799 assert!(!app_updates_supported());
800 clear_platform_app_updater();
801 }
802
803 #[test]
806 fn a_package_with_no_digest_at_all_never_reaches_the_installer() {
807 let _guard = crate::registry::test_service_guard();
808 let updater = Arc::new(RecordingUpdater::new());
809 set_platform_app_updater(updater.clone());
810 let package = UpdatePackage::new("2", "https://example.test/app.apk");
811
812 assert!(!package.is_verifiable());
813 assert_eq!(
814 install_app_update(&package),
815 Err(AppUpdateError::Unverifiable)
816 );
817
818 assert!(updater.installs().is_empty());
819 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
820 clear_platform_app_updater();
821 }
822
823 #[test]
824 fn observer_receives_current_and_changed_status() {
825 let _guard = crate::registry::test_service_guard();
826 set_app_update_status(AppUpdateStatus::Idle);
827 let seen = Arc::new(Mutex::new(Vec::new()));
828 let captured = Arc::clone(&seen);
829 let observer = observe_app_update_status(move |status| {
830 captured
831 .lock()
832 .unwrap_or_else(|error| error.into_inner())
833 .push(status);
834 });
835 set_app_update_status(AppUpdateStatus::Verifying);
836 set_app_update_status(AppUpdateStatus::Installing);
837 assert_eq!(
838 *seen.lock().unwrap_or_else(|error| error.into_inner()),
839 vec![
840 AppUpdateStatus::Idle,
841 AppUpdateStatus::Verifying,
842 AppUpdateStatus::Installing
843 ]
844 );
845 drop(observer);
846 set_app_update_status(AppUpdateStatus::Idle);
847 }
848}