1use std::sync::{
10 atomic::{AtomicU64, Ordering},
11 Arc, Mutex, OnceLock,
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(
309 "the downloaded package does not match its digest (expected {expected}, got {actual})"
310 )]
311 VerificationFailed {
312 expected: String,
314 actual: String,
316 },
317}
318
319pub trait AppUpdater: Send + Sync {
321 fn capabilities(&self) -> AppUpdateCapabilities {
328 AppUpdateCapabilities::default()
329 }
330
331 fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
334 let _ = source;
335 Err(AppUpdateError::Unsupported)
336 }
337
338 fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
344 let _ = package;
345 Err(AppUpdateError::Unsupported)
346 }
347}
348
349#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
358pub struct AppUpdateCapabilities {
359 pub check: bool,
361 pub install: bool,
363}
364
365pub type AppUpdaterRef = Arc<dyn AppUpdater>;
367
368static PLATFORM_UPDATER: ServiceRegistry<dyn AppUpdater> = ServiceRegistry::new();
369
370pub fn set_platform_app_updater(updater: AppUpdaterRef) {
372 PLATFORM_UPDATER.set(updater);
373}
374
375pub fn clear_platform_app_updater() {
377 PLATFORM_UPDATER.clear();
378}
379
380pub fn app_update_capabilities() -> AppUpdateCapabilities {
382 PLATFORM_UPDATER
383 .get()
384 .map(|updater| updater.capabilities())
385 .unwrap_or_default()
386}
387
388pub fn app_updates_supported() -> bool {
390 app_update_capabilities().install
391}
392
393pub fn app_update_checks_supported() -> bool {
399 app_update_capabilities().check
400}
401
402fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
412 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
413 Err(error)
414}
415
416pub 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
430pub 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
467pub 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
493pub 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#[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#[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
539pub 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 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 #[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 #[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 #[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 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 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 #[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}