cranpose-services 0.1.161

Multiplatform system services for Cranpose (HTTP, URI, and OS integrations)
Documentation
use std::sync::{PoisonError, atomic::AtomicUsize};

use super::*;

struct RecordingUpdater {
    checks: AtomicUsize,
    installed: Mutex<Vec<UpdatePackage>>,
}

impl RecordingUpdater {
    fn new() -> Self {
        Self {
            checks: AtomicUsize::new(0),
            installed: Mutex::new(Vec::new()),
        }
    }

    fn installs(&self) -> Vec<UpdatePackage> {
        self.installed
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone()
    }
}

impl AppUpdater for RecordingUpdater {
    fn capabilities(&self) -> AppUpdateCapabilities {
        AppUpdateCapabilities {
            check: true,
            install: true,
        }
    }

    fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
        self.checks.fetch_add(1, Ordering::Relaxed);
        Ok(())
    }

    fn install(&self, package: &UpdatePackage) -> Result<(), AppUpdateError> {
        self.installed
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .push(package.clone());
        Ok(())
    }
}

const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const ABC_SHA256: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";

#[test]
fn the_digest_is_the_one_every_other_implementation_computes() {
    assert_eq!(sha256_hex(b""), EMPTY_SHA256);
    assert_eq!(sha256_hex(b"abc"), ABC_SHA256);
}

#[test]
fn a_feed_digest_is_read_in_the_form_feeds_publish_it() {
    let digest = PackageDigest::parse(&format!("sha256:{}", ABC_SHA256.to_uppercase()))
        .expect("a sha256 digest");
    assert_eq!(digest.algorithm, DigestAlgorithm::Sha256);
    assert_eq!(digest.value, ABC_SHA256, "case is normalised on the way in");
    assert_eq!(digest.to_feed_string(), format!("sha256:{ABC_SHA256}"));
    assert!(digest.is_well_formed());
}

#[test]
fn a_digest_this_framework_cannot_check_is_refused_rather_than_ignored() {
    assert_eq!(PackageDigest::parse("md5:abcdef"), None);
    assert_eq!(PackageDigest::parse("sha256:"), None);
    assert_eq!(PackageDigest::parse("no-algorithm"), None);
    assert!(!PackageDigest::sha256("not hexadecimal").is_well_formed());
    assert!(!PackageDigest::sha256("abcd").is_well_formed(), "too short");
    assert!(matches!(
        DigestVerifier::new(PackageDigest::sha256("abcd")),
        Err(AppUpdateError::MalformedDigest(_))
    ));
}

#[test]
fn a_package_that_matches_its_digest_verifies() {
    assert_eq!(
        verify_package(b"abc", &PackageDigest::sha256(ABC_SHA256)),
        Ok(())
    );
}

#[test]
fn a_package_that_does_not_match_reports_both_digests() {
    let error = verify_package(b"abd", &PackageDigest::sha256(ABC_SHA256))
        .expect_err("a changed byte must not verify");
    match error {
        AppUpdateError::VerificationFailed { expected, actual } => {
            assert_eq!(expected, ABC_SHA256);
            assert_ne!(actual, ABC_SHA256);
            assert_eq!(actual, sha256_hex(b"abd"));
        }
        other => panic!("expected a verification failure, got {other}"),
    }
}

#[test]
fn a_package_read_in_chunks_verifies_the_same_as_one_read_whole() {
    let mut verifier =
        DigestVerifier::new(PackageDigest::sha256(ABC_SHA256)).expect("a well-formed digest");
    assert!(verifier.is_empty());
    verifier.update(b"a");
    verifier.update(b"b");
    verifier.update(b"c");
    assert_eq!(verifier.len(), 3);
    assert_eq!(verifier.finish(), Ok(()));
}

#[test]
fn a_package_carries_what_the_feed_promised_about_it() {
    let package = UpdatePackage::new("1.2.3", "https://example.test/app.apk")
        .with_size(4096)
        .with_digest(PackageDigest::sha256(ABC_SHA256))
        .with_notes("Fixes the thing");
    assert_eq!(package.version, "1.2.3");
    assert_eq!(package.size, Some(4096));
    assert!(package.is_verifiable());
    assert_eq!(package.notes.as_deref(), Some("Fixes the thing"));

    assert!(
        !UpdatePackage::new("1.2.3", "https://example.test/app.apk").is_verifiable(),
        "a feed that published no digest leaves nothing to check against"
    );
}

#[test]
fn request_builds_typed_source() {
    let source = GitHubReleaseUpdate::new("owner/app", "1.2.3", ".apk");
    assert_eq!(source.repository, "owner/app");
    assert_eq!(source.current_version, "1.2.3");
    assert_eq!(source.asset_suffix, ".apk");
}

#[test]
fn operations_publish_and_forward() {
    let _guard = crate::registry::test_service_guard();
    let updater = Arc::new(RecordingUpdater::new());
    set_platform_app_updater(updater.clone());
    assert!(app_updates_supported());
    check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")).unwrap();
    assert_eq!(updater.checks.load(Ordering::Relaxed), 1);
    assert_eq!(app_update_status(), AppUpdateStatus::Checking);

    let package = UpdatePackage::new("2", "https://example.test/app.apk")
        .with_size(4096)
        .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
    install_app_update(&package).unwrap();
    assert_eq!(updater.installs(), vec![package]);
    assert_eq!(
        app_update_status(),
        AppUpdateStatus::Downloading {
            downloaded: 0,
            total: Some(4096)
        },
        "the size the feed published is reported before the first byte arrives"
    );
    clear_platform_app_updater();
    assert!(!app_updates_supported());
}

#[test]
fn a_package_with_an_uncheckable_digest_is_refused_before_it_is_downloaded() {
    let _guard = crate::registry::test_service_guard();
    let updater = Arc::new(RecordingUpdater::new());
    set_platform_app_updater(updater.clone());
    let package = UpdatePackage::new("2", "https://example.test/app.apk")
        .with_digest(PackageDigest::sha256("not-a-digest"));
    assert!(matches!(
        install_app_update(&package),
        Err(AppUpdateError::MalformedDigest(_))
    ));
    assert!(updater.installs().is_empty());
    assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
    clear_platform_app_updater();
}

#[test]
fn a_host_that_cannot_update_says_so_through_the_status_and_not_only_the_result() {
    let _guard = crate::registry::test_service_guard();

    clear_platform_app_updater();
    set_app_update_status(AppUpdateStatus::Idle);
    assert_eq!(
        check_for_app_update(&GitHubReleaseUpdate::new("owner/app", "1", ".apk")),
        Err(AppUpdateError::Unsupported)
    );
    assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));

    struct CheckOnlyUpdater;
    impl AppUpdater for CheckOnlyUpdater {
        fn capabilities(&self) -> AppUpdateCapabilities {
            AppUpdateCapabilities {
                check: true,
                install: false,
            }
        }
        fn check(&self, _source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
            Ok(())
        }
    }
    set_platform_app_updater(Arc::new(CheckOnlyUpdater));
    set_app_update_status(AppUpdateStatus::Idle);
    let package = UpdatePackage::new("2", "https://example.test/app.apk")
        .with_digest(PackageDigest::sha256(sha256_hex(b"package")));
    assert_eq!(
        install_app_update(&package),
        Err(AppUpdateError::Unsupported)
    );
    assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
    assert!(app_update_checks_supported());
    assert!(!app_updates_supported());
    clear_platform_app_updater();
}

#[test]
fn a_package_with_no_digest_at_all_never_reaches_the_installer() {
    let _guard = crate::registry::test_service_guard();
    let updater = Arc::new(RecordingUpdater::new());
    set_platform_app_updater(updater.clone());
    let package = UpdatePackage::new("2", "https://example.test/app.apk");

    assert!(!package.is_verifiable());
    assert_eq!(
        install_app_update(&package),
        Err(AppUpdateError::Unverifiable)
    );

    assert!(updater.installs().is_empty());
    assert!(matches!(app_update_status(), AppUpdateStatus::Error(_)));
    clear_platform_app_updater();
}

#[test]
fn observer_receives_current_and_changed_status() {
    let _guard = crate::registry::test_service_guard();
    set_app_update_status(AppUpdateStatus::Idle);
    let seen = Arc::new(Mutex::new(Vec::new()));
    let captured = Arc::clone(&seen);
    let observer = observe_app_update_status(move |status| {
        captured
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .push(status);
    });
    set_app_update_status(AppUpdateStatus::Verifying);
    set_app_update_status(AppUpdateStatus::Installing);
    assert_eq!(
        *seen.lock().unwrap_or_else(PoisonError::into_inner),
        vec![
            AppUpdateStatus::Idle,
            AppUpdateStatus::Verifying,
            AppUpdateStatus::Installing
        ]
    );
    drop(observer);
    set_app_update_status(AppUpdateStatus::Idle);
}