release-hub 0.3.0

A simple updater for Rust GUI applications
Documentation
use http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
use httpmock::Method::GET;
use httpmock::MockServer;
use release_hub::{Config, EndpointSource, InstallerKind, Update, UpdaterBuilder};
use semver::Version;
use std::{ffi::OsString, path::PathBuf, time::Duration};
use url::Url;

fn test_config(endpoint: Url) -> Config {
    Config {
        dangerous_insecure_transport_protocol: true,
        endpoints: vec![endpoint],
        pubkey: "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3".into(),
        ..Default::default()
    }
}

fn test_update(download_url: Url, signature: &str) -> Update {
    Update {
        current_version: Version::parse("1.0.0").unwrap(),
        version: Version::parse("1.0.1").unwrap(),
        date: None,
        body: Some("Bug fixes".into()),
        raw_json: serde_json::json!({}),
        download_url,
        signature: signature.into(),
        pubkey: include_str!("fixtures/minisign/test.pub").into(),
        target: "linux-x86_64".into(),
        installer_kind: InstallerKind::AppImage,
        headers: HeaderMap::new(),
        timeout: None,
        proxy: None,
        no_proxy: false,
        dangerous_accept_invalid_certs: false,
        dangerous_accept_invalid_hostnames: false,
        extract_path: PathBuf::from("/tmp/release-hub"),
        app_name: "ReleaseHub".into(),
        installer_args: Vec::new(),
    }
}

#[tokio::test]
async fn check_returns_update_when_remote_version_is_newer() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/latest.json");
        then.status(200).body(
            r#"{
                "version": "1.0.1",
                "notes": "Bug fixes",
                "pub_date": "2026-04-21T08:00:00Z",
                "platforms": {
                    "linux-x86_64": {
                        "url": "https://example.com/release-hub.AppImage",
                        "signature": "sig-linux"
                    }
                }
            }"#,
        );
    });

    let endpoint = Url::parse(&server.url("/latest.json")).unwrap();
    let config = test_config(endpoint.clone());

    let updater = UpdaterBuilder::new("ReleaseHub", "1.0.0", config)
        .target("linux-x86_64")
        .source(Box::new(EndpointSource::new(vec![endpoint])))
        .build()
        .unwrap();

    assert_eq!(updater.latest_version(), None);
    let update = updater.check().await.unwrap();
    assert!(update.is_some());
    assert_eq!(
        updater.latest_version(),
        Some(Version::parse("1.0.1").unwrap())
    );
}

#[tokio::test]
async fn check_uses_default_endpoint_source_from_config() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/latest.json");
        then.status(200).body(
            r#"{
                "version": "1.0.1",
                "notes": "Bug fixes",
                "pub_date": "2026-04-21T08:00:00Z",
                "platforms": {
                    "linux-x86_64": {
                        "url": "https://example.com/release-hub.AppImage",
                        "signature": "sig-linux"
                    }
                }
            }"#,
        );
    });

    let endpoint = Url::parse(&server.url("/latest.json")).unwrap();
    let updater = UpdaterBuilder::new("ReleaseHub", "1.0.0", test_config(endpoint))
        .target("linux-x86_64")
        .build()
        .unwrap();

    let update = updater.check().await.unwrap();
    assert!(update.is_some());
}

#[test]
fn build_fails_when_default_config_has_no_endpoints() {
    let config = Config {
        pubkey: "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3".into(),
        ..Default::default()
    };

    match UpdaterBuilder::new("ReleaseHub", "1.0.0", config)
        .target("linux-x86_64")
        .build()
    {
        Ok(_) => panic!("expected build to fail without default endpoints"),
        Err(release_hub::Error::Network(message)) => {
            assert_eq!(message, "no endpoints configured");
        }
        Err(err) => panic!("unexpected error: {err}"),
    }
}

#[tokio::test]
async fn update_download_verifies_minisign_payload() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/release-hub.AppImage");
        then.status(200).body("test");
    });

    let update = test_update(
        Url::parse(&server.url("/release-hub.AppImage")).unwrap(),
        include_str!("fixtures/minisign/test.sig"),
    );

    let mut chunks = Vec::new();
    let bytes = update.download(|chunk| chunks.push(chunk)).await.unwrap();

    assert_eq!(bytes, b"test");
    assert_eq!(chunks, vec![4]);
}

#[tokio::test]
async fn update_download_rejects_invalid_signature() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/release-hub.AppImage");
        then.status(200).body("test");
    });

    let err = test_update(
        Url::parse(&server.url("/release-hub.AppImage")).unwrap(),
        "invalid-signature",
    )
    .download(|_| {})
    .await
    .unwrap_err();

    assert!(matches!(err, release_hub::Error::Minisign(_)));
}

#[tokio::test]
async fn update_download_preserves_configured_headers() {
    let server = MockServer::start();
    let download = server.mock(|when, then| {
        when.method(GET)
            .path("/release-hub.AppImage")
            .header("authorization", "Bearer test-token");
        then.status(200).body("test");
    });

    let endpoint = Url::parse(&server.url("/latest.json")).unwrap();
    let builder = UpdaterBuilder::new("ReleaseHub", "1.0.0", test_config(endpoint))
        .target("linux-x86_64")
        .header(AUTHORIZATION, HeaderValue::from_static("Bearer test-token"))
        .unwrap();

    let mut update = test_update(
        Url::parse(&server.url("/release-hub.AppImage")).unwrap(),
        include_str!("fixtures/minisign/test.sig"),
    );
    update.headers = builder.build().unwrap().headers;

    update.download(|_| {}).await.unwrap();

    download.assert();
}

#[tokio::test]
async fn check_carries_transport_and_install_context_into_update() {
    let server = MockServer::start();
    server.mock(|when, then| {
        when.method(GET).path("/latest.json");
        then.status(200).body(
            r#"{
                "version": "1.0.1",
                "notes": "Bug fixes",
                "pub_date": "2026-04-21T08:00:00Z",
                "platforms": {
                    "linux-x86_64": {
                        "url": "https://example.com/release-hub.AppImage",
                        "signature": "sig-linux"
                    }
                }
            }"#,
        );
    });

    let endpoint = Url::parse(&server.url("/latest.json")).unwrap();
    let proxy = Url::parse("http://127.0.0.1:3128").unwrap();
    let executable_path = PathBuf::from("/tmp/ReleaseHub.app/Contents/MacOS/ReleaseHub");
    let extract_path = PathBuf::from("/tmp/ReleaseHub.app");
    let mut config = test_config(endpoint.clone());
    config.windows = Some(release_hub::WindowsConfig {
        installer_args: vec![OsString::from("/quiet"), OsString::from("/norestart")],
    });
    let updater = UpdaterBuilder::new("ReleaseHub", "1.0.0", config)
        .target("linux-x86_64")
        .source(Box::new(EndpointSource::new(vec![endpoint])))
        .header(AUTHORIZATION, HeaderValue::from_static("Bearer test-token"))
        .unwrap()
        .timeout(Duration::from_secs(9))
        .proxy(proxy.clone())
        .no_proxy()
        .installer_arg("/passive")
        .executable_path(&executable_path)
        .build()
        .unwrap();

    let update = updater.check().await.unwrap().unwrap();

    assert_eq!(
        update.headers.get(AUTHORIZATION),
        Some(&HeaderValue::from_static("Bearer test-token"))
    );
    assert_eq!(update.timeout, Some(Duration::from_secs(9)));
    assert_eq!(update.proxy, Some(proxy));
    assert!(update.no_proxy);
    assert_eq!(update.extract_path, extract_path);
    assert_eq!(update.app_name, "ReleaseHub");
    assert_eq!(
        update.installer_args,
        vec![
            OsString::from("/quiet"),
            OsString::from("/norestart"),
            OsString::from("/passive")
        ]
    );
}