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> {
401 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
402 Err(error)
403}
404
405pub fn check_for_app_update(source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
407 let Some(updater) = PLATFORM_UPDATER.get() else {
408 return publish_failure(AppUpdateError::Unsupported);
409 };
410 if !updater.capabilities().check {
411 return publish_failure(AppUpdateError::Unsupported);
412 }
413 set_app_update_status(AppUpdateStatus::Checking);
414 updater.check(source).inspect_err(|error| {
415 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
416 })
417}
418
419pub fn install_app_update(package: &UpdatePackage) -> Result<(), AppUpdateError> {
426 let Some(updater) = PLATFORM_UPDATER.get() else {
427 return publish_failure(AppUpdateError::Unsupported);
428 };
429 if !updater.capabilities().install {
430 return publish_failure(AppUpdateError::Unsupported);
431 }
432 let error = match &package.digest {
433 None => Some(AppUpdateError::Unverifiable),
434 Some(digest) if !digest.is_well_formed() => {
435 Some(AppUpdateError::MalformedDigest(digest.to_feed_string()))
436 }
437 Some(_) => None,
438 };
439 if let Some(error) = error {
440 return publish_failure(error);
441 }
442 set_app_update_status(AppUpdateStatus::Downloading {
443 downloaded: 0,
444 total: package.size,
445 });
446 updater.install(package).inspect_err(|error| {
447 set_app_update_status(AppUpdateStatus::Error(error.to_string()));
448 })
449}
450
451fn status_slot() -> &'static Mutex<AppUpdateStatus> {
452 static STATUS: OnceLock<Mutex<AppUpdateStatus>> = OnceLock::new();
453 STATUS.get_or_init(|| Mutex::new(AppUpdateStatus::Idle))
454}
455
456pub fn app_update_status() -> AppUpdateStatus {
458 status_slot().lock().map_or_else(
459 |poisoned| poisoned.into_inner().clone(),
460 |status| status.clone(),
461 )
462}
463
464#[cfg(not(target_arch = "wasm32"))]
465type Observer = Arc<dyn Fn(AppUpdateStatus) + Send + Sync>;
466#[cfg(target_arch = "wasm32")]
467type Observer = std::rc::Rc<dyn Fn(AppUpdateStatus)>;
468
469#[cfg(not(target_arch = "wasm32"))]
470fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
471 static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
472 OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
473}
474
475#[cfg(target_arch = "wasm32")]
476thread_local! {
477 static OBSERVERS: std::cell::RefCell<Vec<(u64, Observer)>> = const { std::cell::RefCell::new(Vec::new()) };
478}
479
480static NEXT_OBSERVER_ID: AtomicU64 = AtomicU64::new(1);
481
482pub struct AppUpdateObserver {
484 id: u64,
485}
486
487impl Drop for AppUpdateObserver {
488 fn drop(&mut self) {
489 #[cfg(not(target_arch = "wasm32"))]
490 if let Ok(mut observers) = observers().lock() {
491 observers.retain(|(id, _)| *id != self.id);
492 }
493 #[cfg(target_arch = "wasm32")]
494 OBSERVERS.with(|observers| observers.borrow_mut().retain(|(id, _)| *id != self.id));
495 }
496}
497
498#[cfg(not(target_arch = "wasm32"))]
500pub fn observe_app_update_status(
501 observer: impl Fn(AppUpdateStatus) + Send + Sync + 'static,
502) -> AppUpdateObserver {
503 let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
504 let observer: Observer = Arc::new(observer);
505 if let Ok(mut observers) = observers().lock() {
506 observers.push((id, Arc::clone(&observer)));
507 }
508 observer(app_update_status());
509 AppUpdateObserver { id }
510}
511
512#[cfg(target_arch = "wasm32")]
514pub fn observe_app_update_status(
515 observer: impl Fn(AppUpdateStatus) + 'static,
516) -> AppUpdateObserver {
517 let id = NEXT_OBSERVER_ID.fetch_add(1, Ordering::Relaxed);
518 let observer: Observer = std::rc::Rc::new(observer);
519 OBSERVERS.with(|observers| {
520 observers
521 .borrow_mut()
522 .push((id, std::rc::Rc::clone(&observer)));
523 });
524 observer(app_update_status());
525 AppUpdateObserver { id }
526}
527
528pub fn set_app_update_status(status: AppUpdateStatus) {
530 if let Ok(mut current) = status_slot().lock() {
531 if *current == status {
532 return;
533 }
534 *current = status.clone();
535 }
536 #[cfg(not(target_arch = "wasm32"))]
537 let observers = observers()
538 .lock()
539 .map(|observers| {
540 observers
541 .iter()
542 .map(|(_, observer)| Arc::clone(observer))
543 .collect::<Vec<_>>()
544 })
545 .unwrap_or_default();
546 #[cfg(target_arch = "wasm32")]
547 let observers = OBSERVERS.with(|observers| {
548 observers
549 .borrow()
550 .iter()
551 .map(|(_, observer)| std::rc::Rc::clone(observer))
552 .collect::<Vec<_>>()
553 });
554 for observer in observers {
555 observer(status.clone());
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use std::sync::{PoisonError, atomic::AtomicUsize};
562
563 use super::*;
564
565 struct RecordingUpdater {
566 checks: AtomicUsize,
567 installed: Mutex<Vec<UpdatePackage>>,
568 }
569
570 impl RecordingUpdater {
571 fn new() -> Self {
572 Self {
573 checks: AtomicUsize::new(0),
574 installed: Mutex::new(Vec::new()),
575 }
576 }
577
578 fn installs(&self) -> Vec<UpdatePackage> {
579 self.installed
580 .lock()
581 .unwrap_or_else(PoisonError::into_inner)
582 .clone()
583 }
584 }
585
586 impl AppUpdater for RecordingUpdater {
587 fn capabilities(&self) -> AppUpdateCapabilities {
588 AppUpdateCapabilities {
589 check: true,
590 install: true,
591 }
592 }
593
594 fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
595 self.checks.fetch_add(1, Ordering::Relaxed);
596 Ok(())
597 }
598
599 fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
600 self.installed
601 .lock()
602 .unwrap_or_else(PoisonError::into_inner)
603 .push(package.clone());
604 Ok(())
605 }
606 }
607
608 const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
609 const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
610
611 #[test]
612 fn the_digest_is_the_one_every_other_implementation_computes() {
613 assert_eq!(sha256_hex(b""), EMPTY_SHA256);
614 assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
615 }
616
617 #[test]
618 fn a_feed_digest_is_read_in_the_form_feeds_publish_it() {
619 let digest = PackageDigest::parse(&format!("sha256:{}", ABC_SHA256.to_uppercase()))
620 .expect("a sha256 digest");
621 assert_eq!(digest.algorithm, DigestAlgorithm::Sha256);
622 assert_eq!(digest.value, ABC_SHA256, "case is normalised on the way in");
623 assert_eq!(digest.to_feed_string(), format!("sha256:{ABC_SHA256}"));
624 assert!(digest.is_well_formed());
625 }
626
627 #[test]
628 fn a_digest_this_framework_cannot_check_is_refused_rather_than_ignored() {
629 assert_eq!(PackageDigest::parse("md5:abcdef"), None);
630 assert_eq!(PackageDigest::parse("sha256:"), None);
631 assert_eq!(PackageDigest::parse("no-algorithm"), None);
632 assert!(!PackageDigest::sha256("not hexadecimal").is_well_formed());
633 assert!(!PackageDigest::sha256("abcd").is_well_formed(), "too short");
634 assert!(matches!(
635 DigestVerifier::new(PackageDigest::sha256("abcd")),
636 Err(AppUpdateError::MalformedDigest(_))
637 ));
638 }
639
640 #[test]
641 fn a_package_that_matches_its_digest_verifies() {
642 assert_eq!(
643 verify_package(b"abc", &PackageDigest::sha256(ABC_SHA256)),
644 Ok(())
645 );
646 }
647
648 #[test]
649 fn a_package_that_does_not_match_reports_both_digests() {
650 let error = verify_package(b"abd", &PackageDigest::sha256(ABC_SHA256))
651 .expect_err("a changed byte must not verify");
652 match error {
653 AppUpdateError::VerificationFailed { expected, actual } => {
654 assert_eq!(expected, ABC_SHA256);
655 assert_ne!(actual, ABC_SHA256);
656 assert_eq!(actual, sha256_hex(b"abd"));
657 }
658 other => panic!("expected a verification failure, got {other}"),
659 }
660 }
661
662 #[test]
663 fn a_package_read_in_chunks_verifies_the_same_as_one_read_whole() {
664 let mut verifier =
665 DigestVerifier::new(PackageDigest::sha256(ABC_SHA256)).expect("a well-formed digest");
666 assert!(verifier.is_empty());
667 verifier.update(b"a");
668 verifier.update(b"b");
669 verifier.update(b"c");
670 assert_eq!(verifier.len(), 3);
671 assert_eq!(verifier.finish(), Ok(()));
672 }
673
674 #[test]
675 fn a_package_carries_what_the_feed_promised_about_it() {
676 let package = UpdatePackage::new("1.2.3", "https://example.test/app.apk")
677 .with_size(4096)
678 .with_digest(PackageDigest::sha256(ABC_SHA256))
679 .with_notes("Fixes the thing");
680 assert_eq!(package.version, "1.2.3");
681 assert_eq!(package.size, Some(4096));
682 assert!(package.is_verifiable());
683 assert_eq!(package.notes.as_deref(), Some("Fixes the thing"));
684
685 assert!(
686 !UpdatePackage::new("1.2.3", "https://example.test/app.apk").is_verifiable(),
687 "a feed that published no digest leaves nothing to check against"
688 );
689 }
690
691 #[test]
692 fn request_builds_typed_source() {
693 let source = GitHubReleaseUpdate::new("owner/app", "1.2.3", ".apk");
694 assert_eq!(source.repository, "owner/app");
695 assert_eq!(source.current_version, "1.2.3");
696 assert_eq!(source.asset_suffix, ".apk");
697 }
698
699 #[test]
700 fn operations_publish_and_forward() {
701 let _guard = crate::registry::test_service_guard();
702 let updater = Arc::new(RecordingUpdater::new());
703 set_platform_app_updater(updater.clone());
704 assert!(app_updates_supported());
705 check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")).unwrap();
706 assert_eq!(updater.checks.load(Ordering::Relaxed), 1);
707 assert_eq!(app_update_status(), AppUpdateStatus::Checking);
708
709 let package = UpdatePackage::new("2", "https://example.test/app.apk")
710 .with_size(4096)
711 .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
712 install_app_update(&package).unwrap();
713 assert_eq!(updater.installs(), vec![package]);
714 assert_eq!(
715 app_update_status(),
716 AppUpdateStatus::Downloading {
717 downloaded: 0,
718 total: Some(4096)
719 },
720 "the size the feed published is reported before the first byte arrives"
721 );
722 clear_platform_app_updater();
723 assert!(!app_updates_supported());
724 }
725
726 #[test]
727 fn a_package_with_an_uncheckable_digest_is_refused_before_it_is_downloaded() {
728 let _guard = crate::registry::test_service_guard();
729 let updater = Arc::new(RecordingUpdater::new());
730 set_platform_app_updater(updater.clone());
731 let package = UpdatePackage::new("2", "https://example.test/app.apk")
732 .with_digest(PackageDigest::sha256("not-a-digest"));
733 assert!(matches!(
734 install_app_update(&package),
735 Err(AppUpdateError::MalformedDigest(_))
736 ));
737 assert!(updater.installs().is_empty());
738 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
739 clear_platform_app_updater();
740 }
741
742 #[test]
743 fn a_host_that_cannot_update_says_so_through_the_status_and_not_only_the_result() {
744 let _guard = crate::registry::test_service_guard();
745
746 clear_platform_app_updater();
747 set_app_update_status(AppUpdateStatus::Idle);
748 assert_eq!(
749 check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")),
750 Err(AppUpdateError::Unsupported)
751 );
752 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
753
754 struct CheckOnlyUpdater;
755 impl AppUpdater for CheckOnlyUpdater {
756 fn capabilities(&self) -> AppUpdateCapabilities {
757 AppUpdateCapabilities {
758 check: true,
759 install: false,
760 }
761 }
762 fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
763 Ok(())
764 }
765 }
766 set_platform_app_updater(Arc::new(CheckOnlyUpdater));
767 set_app_update_status(AppUpdateStatus::Idle);
768 let package = UpdatePackage::new("2", "https://example.test/app.apk")
769 .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
770 assert_eq!(
771 install_app_update(&package),
772 Err(AppUpdateError::Unsupported)
773 );
774 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
775 assert!(app_update_checks_supported());
776 assert!(!app_updates_supported());
777 clear_platform_app_updater();
778 }
779
780 #[test]
781 fn a_package_with_no_digest_at_all_never_reaches_the_installer() {
782 let _guard = crate::registry::test_service_guard();
783 let updater = Arc::new(RecordingUpdater::new());
784 set_platform_app_updater(updater.clone());
785 let package = UpdatePackage::new("2", "https://example.test/app.apk");
786
787 assert!(!package.is_verifiable());
788 assert_eq!(
789 install_app_update(&package),
790 Err(AppUpdateError::Unverifiable)
791 );
792
793 assert!(updater.installs().is_empty());
794 assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
795 clear_platform_app_updater();
796 }
797
798 #[test]
799 fn observer_receives_current_and_changed_status() {
800 let _guard = crate::registry::test_service_guard();
801 set_app_update_status(AppUpdateStatus::Idle);
802 let seen = Arc::new(Mutex::new(Vec::new()));
803 let captured = Arc::clone(&seen);
804 let observer = observe_app_update_status(move |status| {
805 captured
806 .lock()
807 .unwrap_or_else(PoisonError::into_inner)
808 .push(status);
809 });
810 set_app_update_status(AppUpdateStatus::Verifying);
811 set_app_update_status(AppUpdateStatus::Installing);
812 assert_eq!(
813 *seen.lock().unwrap_or_else(PoisonError::into_inner),
814 vec![
815 AppUpdateStatus::Idle,
816 AppUpdateStatus::Verifying,
817 AppUpdateStatus::Installing
818 ]
819 );
820 drop(observer);
821 set_app_update_status(AppUpdateStatus::Idle);
822 }
823}