1use crate::registry::ServiceRegistry;
10use sha2::{Digest, Sha256};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, OnceLock};
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
16pub enum DigestAlgorithm {
17 #[default]
19 Sha256,
20}
21
22impl DigestAlgorithm {
23 pub fn name(self) -> &'static str {
26 match self {
27 DigestAlgorithm::Sha256 => "sha256",
28 }
29 }
30
31 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
44pub struct PackageDigest {
45 pub algorithm: DigestAlgorithm,
46 pub value: String,
48}
49
50impl PackageDigest {
51 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 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 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 pub fn to_feed_string(&self) -> String {
84 format!("{}:{}", self.algorithm.name(), self.value)
85 }
86}
87
88pub 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
104pub struct DigestVerifier {
111 expected: PackageDigest,
112 hasher: Sha256,
113 len: u64,
114}
115
116impl DigestVerifier {
117 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 pub fn update(&mut self, chunk: &[u8]) {
131 self.hasher.update(chunk);
132 self.len += chunk.len() as u64;
133 }
134
135 pub fn len(&self) -> u64 {
137 self.len
138 }
139
140 pub fn is_empty(&self) -> bool {
142 self.len == 0
143 }
144
145 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
159pub 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
168pub struct UpdatePackage {
169 pub version: String,
171 pub download_url: String,
173 pub size: Option<u64>,
175 pub digest: Option<PackageDigest>,
183 pub notes: Option<String>,
185}
186
187impl UpdatePackage {
188 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 pub fn is_verifiable(&self) -> bool {
214 self.digest
215 .as_ref()
216 .is_some_and(PackageDigest::is_well_formed)
217 }
218}
219
220#[derive(Clone, Debug, PartialEq, Eq, Hash)]
222pub struct GitHubReleaseUpdate {
223 pub repository: String,
225 pub current_version: String,
227 pub asset_suffix: String,
229}
230
231impl GitHubReleaseUpdate {
232 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
248pub enum AppUpdateStatus {
249 #[default]
251 Idle,
252 Checking,
254 UpToDate,
256 Available {
258 package: UpdatePackage,
261 },
262 Downloading {
264 downloaded: u64,
266 total: Option<u64>,
268 },
269 Verifying,
272 AwaitingConfirmation,
274 Installing,
276 Error(String),
278}
279
280#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
282pub enum AppUpdateError {
283 #[error("application updates are unavailable on this platform")]
285 Unsupported,
286 #[error("application update request failed: {0}")]
288 Request(String),
289 #[error("the release feed published no digest for this package, so it cannot be checked")]
296 Unverifiable,
297 #[error("the release feed published a digest that cannot be checked: {0}")]
302 MalformedDigest(String),
303 #[error(
305 "the downloaded package does not match its digest (expected {expected}, got {actual})"
306 )]
307 VerificationFailed {
308 expected: String,
310 actual: String,
312 },
313}
314
315pub trait AppUpdater: Send + Sync {
317 fn capabilities(&self) -> AppUpdateCapabilities {
324 AppUpdateCapabilities::default()
325 }
326
327 fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
330 let _ = source;
331 Err(AppUpdateError::Unsupported)
332 }
333
334 fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
340 let _ = package;
341 Err(AppUpdateError::Unsupported)
342 }
343}
344
345#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
354pub struct AppUpdateCapabilities {
355 pub check: bool,
357 pub install: bool,
359}
360
361pub type AppUpdaterRef = Arc<dyn AppUpdater>;
363
364static PLATFORM_UPDATER: ServiceRegistry<dyn AppUpdater> = ServiceRegistry::new();
365
366pub fn set_platform_app_updater(updater: AppUpdaterRef) {
368 PLATFORM_UPDATER.set(updater);
369}
370
371pub fn clear_platform_app_updater() {
373 PLATFORM_UPDATER.clear();
374}
375
376pub fn app_update_capabilities() -> AppUpdateCapabilities {
378 PLATFORM_UPDATER
379 .get()
380 .map(|updater| updater.capabilities())
381 .unwrap_or_default()
382}
383
384pub fn app_updates_supported() -> bool {
386 app_update_capabilities().install
387}
388
389pub fn app_update_checks_supported() -> bool {
395 app_update_capabilities().check
396}
397
398fn publish_failure(error: AppUpdateError) -> Result<(), AppUpdateError> {
408 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
409 Err(error)
410}
411
412pub 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
426pub 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
463pub 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
489pub 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#[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#[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
535pub 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 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 #[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 #[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 #[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 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 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 #[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}