mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
fix(updater): make client auto-update work end-to-end and Linux server self-update verifiable
- client: update endpoint now sends {{target}}-{{arch}}-{{bundle_type}} so the
server-echoed platforms key matches the updater plugin's
{os}-{arch}-{installer} lookup (previously bare {{target}} produced a key
the plugin never matches, so no update was ever surfaced)
- client: TOFU cert pin is scoped to the OwnCord server host via
HostScopedVerifier; the GitHub installer download validates against web PKI
instead of failing the pinned-fingerprint check on every install
- client: check/install share one build_updater helper so the two paths cannot
diverge; tauri-plugin-updater minor-pinned per its configure_client guidance
- server: client-update endpoint serves target-specific artifacts (NSIS,
per-arch AppImage) and returns 204 for targets without a published updater
artifact (deb, darwin) instead of always serving the Windows NSIS installer
- release: server-update-manifest.json now binds both OS assets (legacy
top-level pair kept pointing at the Windows binary so deployed servers still
verify); VerifyReleaseManifest resolves the entry matching the downloaded
asset, fixing Linux server self-update
- release: ARM64 staging renames installer, tar.gz and .sig consistently so
signatures keep pairing and arch-less names cannot collide with x86_64 assets
- ci: run cargo test --lib (Rust #[cfg(test)] code was never compiled in CI);
merge the two ptt tests that raced on the global PTT_VKEY atomic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,12 @@ jobs:
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
# Without this, #[cfg(test)] code is never compiled or run in CI
|
||||
# (clippy above skips test targets), so Rust unit tests would rot.
|
||||
- name: Rust unit tests
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
run: cargo test --lib
|
||||
|
||||
- name: Security audit (Rust dependencies)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
run: |
|
||||
|
||||
@@ -237,20 +237,18 @@ jobs:
|
||||
run: |
|
||||
mkdir -p linux-arm64-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
# AppImage (ensure arch is in filename)
|
||||
for f in "$BUNDLE_DIR"/appimage/*.AppImage; do
|
||||
# AppImage + updater artifact (.tar.gz) + signatures. Every filename
|
||||
# must carry the arch: FindClientAssets matches on the
|
||||
# _aarch64.AppImage.tar.gz suffix, and arch-less names would collide
|
||||
# with the x86_64 assets when both artifact sets are downloaded into
|
||||
# the same linux/ directory at publish time. Inserting _aarch64
|
||||
# before ".AppImage" renames installer, tar.gz, and .sig
|
||||
# consistently, so signatures keep pairing with their artifacts.
|
||||
for f in "$BUNDLE_DIR"/appimage/*.AppImage "$BUNDLE_DIR"/appimage/*.AppImage.tar.gz "$BUNDLE_DIR"/appimage/*.sig; do
|
||||
[ -f "$f" ] || continue
|
||||
[[ "$f" == *.sig ]] && continue
|
||||
dest="linux-arm64-staging/$(basename "$f")"
|
||||
# Append _aarch64 if bundler omits arch from filename
|
||||
[[ "$(basename "$f")" == *aarch64* ]] || dest="${dest%.AppImage}_aarch64.AppImage"
|
||||
cp "$f" "$dest"
|
||||
done
|
||||
for f in "$BUNDLE_DIR"/appimage/*.AppImage.tar.gz; do
|
||||
[ -f "$f" ] && cp "$f" linux-arm64-staging/
|
||||
done
|
||||
for f in "$BUNDLE_DIR"/appimage/*.sig; do
|
||||
[ -f "$f" ] && cp "$f" linux-arm64-staging/
|
||||
base="$(basename "$f")"
|
||||
[[ "$base" == *aarch64* ]] || base="${base/.AppImage/_aarch64.AppImage}"
|
||||
cp "$f" "linux-arm64-staging/$base"
|
||||
done
|
||||
# .deb
|
||||
for f in "$BUNDLE_DIR"/deb/*.deb; do
|
||||
@@ -376,11 +374,17 @@ jobs:
|
||||
(cd linux && sha256sum *) >> checksums.sha256
|
||||
sha256sum owncord-src-*.tar.gz >> checksums.sha256
|
||||
|
||||
# The legacy top-level asset/sha256 pair stays bound to the Windows
|
||||
# binary so already-deployed servers (which only understand the
|
||||
# single-asset schema) can still verify and update; the assets list
|
||||
# binds every OS. Server-side schema: updater.releaseManifest.
|
||||
- name: Generate server update manifest
|
||||
shell: bash
|
||||
run: |
|
||||
SERVER_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}')
|
||||
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s"}' "$VERSION" "$SERVER_HASH" > windows/server-update-manifest.json
|
||||
WIN_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}')
|
||||
LINUX_HASH=$(sha256sum linux/chatserver-linux-amd64.tar.gz | awk '{print $1}')
|
||||
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}' \
|
||||
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json
|
||||
|
||||
- name: Sign server update assets
|
||||
working-directory: Client/tauri-client
|
||||
|
||||
Generated
+1
@@ -2955,6 +2955,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"webpki-roots 1.0.6",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
# Minor-pinned per the plugin's own guidance: configure_client hands it a
|
||||
# preconfigured rustls ClientConfig, and a 2.x minor bump can change the
|
||||
# plugin's bundled reqwest/rustls and break that seam at runtime.
|
||||
tauri-plugin-updater = "2.10"
|
||||
tauri-plugin-process = "2"
|
||||
url = "2"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
|
||||
@@ -39,6 +42,10 @@ futures-util = "0.3.32"
|
||||
tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
# Mozilla root bundle for the updater's HostScopedVerifier: non-pinned hosts
|
||||
# (the GitHub installer download) get web-PKI validation. Already in the
|
||||
# dependency tree via tokio-tungstenite's rustls-tls-webpki-roots feature.
|
||||
webpki-roots = "1"
|
||||
ring = "0.17"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -479,13 +479,20 @@ pub async fn ptt_listen_for_key() -> i32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// PTT_VKEY is a process-global AtomicI32 and cargo runs tests on parallel
|
||||
// threads, so every mutating assertion lives in this ONE test — splitting
|
||||
// them across tests makes the get/set assertions racy.
|
||||
#[test]
|
||||
fn ptt_set_key_accepts_valid_codes() {
|
||||
fn ptt_set_key_accepts_valid_codes_and_get_reflects_them() {
|
||||
assert!(ptt_set_key(0).is_ok());
|
||||
assert!(ptt_set_key(1).is_ok());
|
||||
assert!(ptt_set_key(0x20).is_ok()); // Space
|
||||
assert!(ptt_set_key(0x41).is_ok()); // A
|
||||
assert!(ptt_set_key(254).is_ok());
|
||||
|
||||
ptt_set_key(0x41).unwrap(); // A
|
||||
assert_eq!(ptt_get_key(), 0x41);
|
||||
ptt_set_key(0).unwrap();
|
||||
assert_eq!(ptt_get_key(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,14 +524,6 @@ mod tests {
|
||||
assert!(!is_allowed_ptt_capture_vk(0x5B)); // Meta
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_get_key_reflects_set_key() {
|
||||
ptt_set_key(0x41).unwrap();
|
||||
assert_eq!(ptt_get_key(), 0x41);
|
||||
ptt_set_key(0).unwrap();
|
||||
assert_eq!(ptt_get_key(), 0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_keycode_round_trips_for_common_keys() {
|
||||
|
||||
@@ -181,6 +181,103 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rustls verifier that applies the pinned-fingerprint check ONLY to the
|
||||
/// named host and normal web-PKI validation to every other host. Used by the
|
||||
/// updater, whose single HTTP client talks both to the (possibly self-signed,
|
||||
/// TOFU-pinned) OwnCord server for update metadata and to GitHub for the
|
||||
/// installer download — a client-wide pin would reject GitHub's certificate.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HostScopedVerifier {
|
||||
pinned_host: String,
|
||||
pinned: PinnedVerifier,
|
||||
default: Arc<dyn rustls::client::danger::ServerCertVerifier>,
|
||||
}
|
||||
|
||||
impl HostScopedVerifier {
|
||||
pub(crate) fn new(pinned_host: String, expected_fingerprint: String) -> Result<Self, String> {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let default = rustls::client::WebPkiServerVerifier::builder_with_provider(
|
||||
Arc::new(roots),
|
||||
Arc::new(rustls::crypto::ring::default_provider()),
|
||||
)
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build web-PKI verifier: {e}"))?;
|
||||
Ok(Self::with_default(pinned_host, expected_fingerprint, default))
|
||||
}
|
||||
|
||||
/// Seam for tests: inject the verifier used for non-pinned hosts.
|
||||
fn with_default(
|
||||
pinned_host: String,
|
||||
expected_fingerprint: String,
|
||||
default: Arc<dyn rustls::client::danger::ServerCertVerifier>,
|
||||
) -> Self {
|
||||
// url::Url wraps IPv6 hosts in brackets; ServerName renders them bare.
|
||||
let pinned_host = pinned_host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.to_ascii_lowercase();
|
||||
Self {
|
||||
pinned_host,
|
||||
pinned: PinnedVerifier::new(expected_fingerprint),
|
||||
default,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pinned_host(&self, server_name: &rustls::pki_types::ServerName<'_>) -> bool {
|
||||
match server_name {
|
||||
rustls::pki_types::ServerName::DnsName(d) => {
|
||||
d.as_ref().eq_ignore_ascii_case(&self.pinned_host)
|
||||
}
|
||||
rustls::pki_types::ServerName::IpAddress(ip) => {
|
||||
std::net::IpAddr::from(*ip).to_string() == self.pinned_host
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
server_name: &rustls::pki_types::ServerName<'_>,
|
||||
ocsp_response: &[u8],
|
||||
now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if self.is_pinned_host(server_name) {
|
||||
self.pinned
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
} else {
|
||||
self.default
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls12(message, cert, dss)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls13(message, cert, dss)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
default_verify_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
// ── store keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
|
||||
@@ -311,4 +408,125 @@ mod tests {
|
||||
"e3:b0:c4:42:98:fc:1c:14:9a:fb:f4:c8:99:6f:b9:24:27:ae:41:e4:64:9b:93:4c:a4:95:99:1b:78:52:b8:55"
|
||||
);
|
||||
}
|
||||
|
||||
// ── HostScopedVerifier ──────────────────────────────────────────────────
|
||||
|
||||
/// Stub for the non-pinned-host verifier: records nothing, just returns a
|
||||
/// fixed verdict so tests can prove which path a connection was routed to.
|
||||
#[derive(Debug)]
|
||||
struct StubVerifier {
|
||||
accept: bool,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for StubVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if self.accept {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
} else {
|
||||
Err(rustls::Error::General("stub rejected".into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls12(message, cert, dss)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls13(message, cert, dss)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
default_verify_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
fn host_scoped(pinned_host: &str, cert_bytes: &[u8], stub_accepts: bool) -> HostScopedVerifier {
|
||||
HostScopedVerifier::with_default(
|
||||
pinned_host.to_string(),
|
||||
fingerprint_hex(cert_bytes),
|
||||
Arc::new(StubVerifier { accept: stub_accepts }),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify(
|
||||
v: &HostScopedVerifier,
|
||||
host: &str,
|
||||
cert_bytes: &[u8],
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
use rustls::client::danger::ServerCertVerifier;
|
||||
let cert = rustls::pki_types::CertificateDer::from(cert_bytes.to_vec());
|
||||
let name = rustls::pki_types::ServerName::try_from(host.to_string()).unwrap();
|
||||
v.verify_server_cert(
|
||||
&cert,
|
||||
&[],
|
||||
&name,
|
||||
&[],
|
||||
rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_pins_matching_host() {
|
||||
// Stub rejects, so success proves the PINNED path handled it.
|
||||
let v = host_scoped("chat.example.com", b"server-cert", false);
|
||||
assert!(verify(&v, "chat.example.com", b"server-cert").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_rejects_wrong_cert_on_pinned_host() {
|
||||
let err = verify(
|
||||
&host_scoped("chat.example.com", b"server-cert", true),
|
||||
"chat.example.com",
|
||||
b"mitm-cert",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("fingerprint mismatch"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_delegates_other_hosts_to_default() {
|
||||
// Cert does NOT match the pin; success proves the DEFAULT path handled it.
|
||||
let v = host_scoped("chat.example.com", b"server-cert", true);
|
||||
assert!(verify(&v, "github.com", b"github-cert").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_default_rejection_propagates() {
|
||||
let err = verify(
|
||||
&host_scoped("chat.example.com", b"server-cert", false),
|
||||
"github.com",
|
||||
b"github-cert",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("stub rejected"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_host_match_is_case_insensitive() {
|
||||
let v = host_scoped("Chat.Example.COM", b"server-cert", false);
|
||||
assert!(verify(&v, "chat.example.com", b"server-cert").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_scoped_matches_ip_pinned_host() {
|
||||
let v = host_scoped("192.168.1.10", b"server-cert", false);
|
||||
assert!(verify(&v, "192.168.1.10", b"server-cert").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier};
|
||||
use crate::tofu::{cert_store_key, load_stored_fingerprint, HostScopedVerifier};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateCheckResult {
|
||||
@@ -36,17 +36,25 @@ fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
|
||||
Ok(cert_store_key(&raw))
|
||||
}
|
||||
|
||||
/// Build a rustls ClientConfig that validates the server cert against the
|
||||
/// TOFU-pinned fingerprint. Falls back to system certs if no fingerprint
|
||||
/// is stored (server uses a real CA cert).
|
||||
/// Build a rustls ClientConfig for the updater. When a TOFU fingerprint is
|
||||
/// stored, the pin is enforced for the OwnCord server's host ONLY — the same
|
||||
/// HTTP client also downloads the installer from GitHub, whose certificate
|
||||
/// must pass normal web-PKI validation instead (a client-wide pin would
|
||||
/// reject it and every install would fail).
|
||||
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
|
||||
let store_key = extract_host_for_cert_store(server_url)?;
|
||||
let fingerprint = load_stored_fingerprint(app, &store_key)?;
|
||||
match fingerprint {
|
||||
Some(fp) => {
|
||||
let parsed = url::Url::parse(server_url)
|
||||
.map_err(|e| format!("failed to parse server URL: {e}"))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server URL has no host".to_string())?;
|
||||
let verifier = HostScopedVerifier::new(host.to_string(), fp)?;
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinnedVerifier::new(fp)))
|
||||
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||
.with_no_client_auth();
|
||||
Ok(Some(config))
|
||||
}
|
||||
@@ -57,6 +65,57 @@ fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri updater endpoint on the given server. `{{target}}-{{arch}}-{{bundle_type}}`
|
||||
/// (expanded by the updater plugin to e.g. "windows-x86_64-nsis") must match
|
||||
/// the `{os}-{arch}-{installer}` key the plugin looks up FIRST in the response
|
||||
/// `platforms` map — the server echoes this path segment back as that key.
|
||||
/// The bundle type matters: a deb-installed client must get 204, not the
|
||||
/// AppImage archive, or its install step rejects every update.
|
||||
fn build_update_endpoint(server_url: &str, current_version: &str) -> String {
|
||||
format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}-{{{{arch}}}}-{{{{bundle_type}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build an updater wired to the given OwnCord server: dynamic endpoint plus
|
||||
/// host-scoped TOFU TLS. Shared by check and install so the two paths can
|
||||
/// never diverge on endpoint format or trust configuration.
|
||||
fn build_updater(
|
||||
app: &AppHandle,
|
||||
server_url: &str,
|
||||
) -> Result<tauri_plugin_updater::Updater, String> {
|
||||
validate_server_url(server_url)?;
|
||||
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = build_update_endpoint(server_url, ¤t_version);
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
// Use TOFU-pinned certificate for self-signed servers, or system certs
|
||||
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
|
||||
let tls_config = build_tls_config(app, server_url)?;
|
||||
let mut builder = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?;
|
||||
if let Some(config) = tls_config {
|
||||
let config = Arc::new(config);
|
||||
builder =
|
||||
builder.configure_client(move |client| client.use_preconfigured_tls((*config).clone()));
|
||||
}
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))
|
||||
}
|
||||
|
||||
/// Validate that a server URL is safe for the updater to connect to.
|
||||
fn validate_server_url(server_url: &str) -> Result<(), String> {
|
||||
let trimmed = server_url.trim_end_matches('/');
|
||||
@@ -80,40 +139,7 @@ pub async fn check_client_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<UpdateCheckResult, String> {
|
||||
validate_server_url(&server_url)?;
|
||||
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
// Use TOFU-pinned certificate for self-signed servers, or system certs
|
||||
// for CA-signed servers. Never blindly accept invalid certs (BUG-134).
|
||||
let tls_config = build_tls_config(&app, &server_url)?;
|
||||
let mut builder = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?;
|
||||
if let Some(config) = tls_config {
|
||||
let config = Arc::new(config);
|
||||
builder = builder.configure_client(move |client| {
|
||||
client.use_preconfigured_tls((*config).clone())
|
||||
});
|
||||
}
|
||||
let updater = builder
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
let updater = build_updater(&app, &server_url)?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
@@ -142,39 +168,7 @@ pub async fn download_and_install_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<(), String> {
|
||||
validate_server_url(&server_url)?;
|
||||
|
||||
let current_version = app
|
||||
.config()
|
||||
.version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "0.0.0".to_string());
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/v1/client-update/{{{{target}}}}/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
current_version,
|
||||
);
|
||||
|
||||
let url: url::Url = endpoint
|
||||
.parse()
|
||||
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
|
||||
|
||||
// Use TOFU-pinned certificate for self-signed servers (BUG-134).
|
||||
let tls_config = build_tls_config(&app, &server_url)?;
|
||||
let mut builder = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![url])
|
||||
.map_err(|e| format!("failed to set endpoints: {e}"))?;
|
||||
if let Some(config) = tls_config {
|
||||
let config = Arc::new(config);
|
||||
builder = builder.configure_client(move |client| {
|
||||
client.use_preconfigured_tls((*config).clone())
|
||||
});
|
||||
}
|
||||
let updater = builder
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build updater: {e}"))?;
|
||||
let updater = build_updater(&app, &server_url)?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
@@ -202,3 +196,28 @@ pub async fn download_and_install_update(
|
||||
None => Err("no update available".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn endpoint_includes_target_arch_and_bundle_type_variables() {
|
||||
// "{{target}}-{{arch}}-{{bundle_type}}" (e.g. "windows-x86_64-nsis")
|
||||
// must match the "{os}-{arch}-{installer}" key the updater plugin
|
||||
// looks up first in the response platforms map — the server echoes
|
||||
// this path segment back as that key.
|
||||
assert_eq!(
|
||||
build_update_endpoint("https://chat.example.com/", "1.2.3"),
|
||||
"https://chat.example.com/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/1.2.3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_keeps_non_default_port() {
|
||||
assert_eq!(
|
||||
build_update_endpoint("https://chat.example.com:8443", "0.0.0"),
|
||||
"https://chat.example.com:8443/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/0.0.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,14 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the .nsis.zip and .nsis.zip.sig assets from the release.
|
||||
clientAssets := u.FindClientAssets()
|
||||
nsisURL := clientAssets.InstallerURL
|
||||
// Find the updater artifact and its signature for the requested
|
||||
// target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis").
|
||||
// Targets without a published updater artifact get 204 — never a
|
||||
// foreign OS's or foreign installer's artifact.
|
||||
clientAssets := u.FindClientAssets(target)
|
||||
installerURL := clientAssets.InstallerURL
|
||||
sigURL := clientAssets.SignatureURL
|
||||
if nsisURL == "" || sigURL == "" {
|
||||
if installerURL == "" || sigURL == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
@@ -82,7 +85,7 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
Platforms: map[string]tauriPlatformResponse{
|
||||
target: {
|
||||
Signature: strings.TrimSpace(sigContent),
|
||||
URL: nsisURL,
|
||||
URL: installerURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -12,7 +13,8 @@ import (
|
||||
)
|
||||
|
||||
// fakeGitHubRelease returns a test HTTP server that mimics the GitHub
|
||||
// Releases API, serving a release with the given tag and NSIS assets.
|
||||
// Releases API, serving a release with the given tag and the client update
|
||||
// assets for every platform the release workflow publishes.
|
||||
// Asset download URLs point back to the test server so FetchTextAsset works.
|
||||
func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server {
|
||||
t.Helper()
|
||||
@@ -20,29 +22,40 @@ func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server {
|
||||
var srv *httptest.Server
|
||||
mux := http.NewServeMux()
|
||||
|
||||
assetNames := []string{
|
||||
"OwnCord_1.0.0_x64-setup.nsis.zip",
|
||||
"OwnCord_1.0.0_x64-setup.nsis.zip.sig",
|
||||
"OwnCord_1.0.0_amd64.AppImage.tar.gz",
|
||||
"OwnCord_1.0.0_amd64.AppImage.tar.gz.sig",
|
||||
"OwnCord_1.0.0_aarch64.AppImage.tar.gz",
|
||||
"OwnCord_1.0.0_aarch64.AppImage.tar.gz.sig",
|
||||
}
|
||||
|
||||
mux.HandleFunc("/repos/test/repo/releases/latest", func(w http.ResponseWriter, r *http.Request) {
|
||||
assets := make([]map[string]any, 0, len(assetNames))
|
||||
for _, name := range assetNames {
|
||||
assets = append(assets, map[string]any{
|
||||
"name": name,
|
||||
"browser_download_url": srv.URL + "/download/" + name,
|
||||
})
|
||||
}
|
||||
resp := map[string]any{
|
||||
"tag_name": tag,
|
||||
"body": "Release notes here",
|
||||
"html_url": "https://github.com/test/repo/releases/" + tag,
|
||||
"assets": []map[string]any{
|
||||
{
|
||||
"name": "OwnCord_1.0.0_x64-setup.nsis.zip",
|
||||
"browser_download_url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip",
|
||||
},
|
||||
{
|
||||
"name": "OwnCord_1.0.0_x64-setup.nsis.zip.sig",
|
||||
"browser_download_url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip.sig",
|
||||
},
|
||||
},
|
||||
"assets": assets,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
// Serve the signature file content.
|
||||
mux.HandleFunc("/download/OwnCord_1.0.0_x64-setup.nsis.zip.sig", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
|
||||
// Serve signature file content for any .sig asset.
|
||||
mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".sig") {
|
||||
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
srv = httptest.NewServer(mux)
|
||||
@@ -50,6 +63,25 @@ func fakeGitHubRelease(t *testing.T, tag string) *httptest.Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
// platformEntry decodes the response body and returns the platforms entry for
|
||||
// the given target, failing the test if it is missing.
|
||||
func platformEntry(t *testing.T, rr *httptest.ResponseRecorder, target string) map[string]any {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
platforms, ok := resp["platforms"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing platforms map: %v", resp)
|
||||
}
|
||||
entry, ok := platforms[target].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("platforms missing key %q: %v", target, platforms)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func buildClientUpdateRouter(u *updater.Updater) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
api.MountClientUpdateRoute(r, u)
|
||||
@@ -63,7 +95,7 @@ func TestClientUpdate_NewVersionAvailable(t *testing.T) {
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
@@ -91,7 +123,7 @@ func TestClientUpdate_AlreadyLatest(t *testing.T) {
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
@@ -108,7 +140,111 @@ func TestClientUpdate_FutureVersion(t *testing.T) {
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
// Client has a newer version than the release.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/2.0.0", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/2.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdate_WindowsTargetGetsNSISInstaller(t *testing.T) {
|
||||
srv := fakeGitHubRelease(t, "v2.0.0")
|
||||
u := updater.NewUpdater("1.0.0", "", "test", "repo")
|
||||
u.SetBaseURL(srv.URL)
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
entry := platformEntry(t, rr, "windows-x86_64-nsis")
|
||||
url, _ := entry["url"].(string)
|
||||
if !strings.HasSuffix(url, "_x64-setup.nsis.zip") {
|
||||
t.Errorf("windows url = %q, want NSIS installer", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdate_LinuxTargetGetsAppImage(t *testing.T) {
|
||||
srv := fakeGitHubRelease(t, "v2.0.0")
|
||||
u := updater.NewUpdater("1.0.0", "", "test", "repo")
|
||||
u.SetBaseURL(srv.URL)
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-x86_64-appimage/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
entry := platformEntry(t, rr, "linux-x86_64-appimage")
|
||||
url, _ := entry["url"].(string)
|
||||
if !strings.HasSuffix(url, "_amd64.AppImage.tar.gz") {
|
||||
t.Errorf("linux url = %q, want x86_64 AppImage updater archive", url)
|
||||
}
|
||||
if sig, _ := entry["signature"].(string); sig == "" {
|
||||
t.Error("linux platform entry missing signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdate_LinuxArm64TargetGetsAarch64AppImage(t *testing.T) {
|
||||
srv := fakeGitHubRelease(t, "v2.0.0")
|
||||
u := updater.NewUpdater("1.0.0", "", "test", "repo")
|
||||
u.SetBaseURL(srv.URL)
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-aarch64-appimage/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
entry := platformEntry(t, rr, "linux-aarch64-appimage")
|
||||
url, _ := entry["url"].(string)
|
||||
if !strings.HasSuffix(url, "_aarch64.AppImage.tar.gz") {
|
||||
t.Errorf("linux arm64 url = %q, want aarch64 AppImage updater archive", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdate_UnsupportedTargetNoContent(t *testing.T) {
|
||||
srv := fakeGitHubRelease(t, "v2.0.0")
|
||||
u := updater.NewUpdater("1.0.0", "", "test", "repo")
|
||||
u.SetBaseURL(srv.URL)
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
// No darwin client is published; the updater must not be offered a
|
||||
// Windows installer for it.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/darwin-aarch64-app/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204; body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdate_DebTargetNoContent(t *testing.T) {
|
||||
srv := fakeGitHubRelease(t, "v2.0.0")
|
||||
u := updater.NewUpdater("1.0.0", "", "test", "repo")
|
||||
u.SetBaseURL(srv.URL)
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
// The release ships .deb packages but no signed deb UPDATER artifact.
|
||||
// A deb client falling back to the AppImage archive would fail install
|
||||
// forever (the plugin's install_deb rejects gzip bytes), so it must get
|
||||
// 204 rather than an artifact for a different installer.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/linux-x86_64-deb/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
@@ -129,7 +265,7 @@ func TestClientUpdate_GitHubError(t *testing.T) {
|
||||
|
||||
router := buildClientUpdateRouter(u)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64/1.0.0", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
|
||||
@@ -14,30 +14,49 @@ import (
|
||||
func TestFindClientAssets_NilCache(t *testing.T) {
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
|
||||
ca := u.FindClientAssets()
|
||||
ca := u.FindClientAssets("windows-x86_64-nsis")
|
||||
if ca.InstallerURL != "" || ca.SignatureURL != "" {
|
||||
t.Error("expected empty ClientAssets when no cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindClientAssets_WithMatchingAssets(t *testing.T) {
|
||||
func TestFindClientAssets_ByTarget(t *testing.T) {
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
u.mu.Lock()
|
||||
u.cache = &UpdateInfo{
|
||||
Assets: []Asset{
|
||||
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip", DownloadURL: "https://example.com/installer.zip"},
|
||||
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip.sig", DownloadURL: "https://example.com/installer.zip.sig"},
|
||||
{Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz", DownloadURL: "https://example.com/amd64.AppImage.tar.gz"},
|
||||
{Name: "OwnCord_1.0.0_amd64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/amd64.AppImage.tar.gz.sig"},
|
||||
{Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz"},
|
||||
{Name: "OwnCord_1.0.0_aarch64.AppImage.tar.gz.sig", DownloadURL: "https://example.com/aarch64.AppImage.tar.gz.sig"},
|
||||
{Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"},
|
||||
},
|
||||
}
|
||||
u.mu.Unlock()
|
||||
|
||||
ca := u.FindClientAssets()
|
||||
if ca.InstallerURL != "https://example.com/installer.zip" {
|
||||
t.Errorf("InstallerURL = %q, want installer URL", ca.InstallerURL)
|
||||
cases := []struct {
|
||||
target string
|
||||
wantInstaller string
|
||||
wantSig string
|
||||
}{
|
||||
{"windows-x86_64-nsis", "https://example.com/installer.zip", "https://example.com/installer.zip.sig"},
|
||||
{"linux-x86_64-appimage", "https://example.com/amd64.AppImage.tar.gz", "https://example.com/amd64.AppImage.tar.gz.sig"},
|
||||
{"linux-aarch64-appimage", "https://example.com/aarch64.AppImage.tar.gz", "https://example.com/aarch64.AppImage.tar.gz.sig"},
|
||||
// No deb updater artifact is published — a deb client must get
|
||||
// nothing rather than an AppImage archive its installer rejects.
|
||||
{"linux-x86_64-deb", "", ""},
|
||||
{"darwin-aarch64-app", "", ""},
|
||||
{"windows-x86_64-unknown", "", ""},
|
||||
{"", "", ""},
|
||||
}
|
||||
if ca.SignatureURL != "https://example.com/installer.zip.sig" {
|
||||
t.Errorf("SignatureURL = %q, want signature URL", ca.SignatureURL)
|
||||
for _, tc := range cases {
|
||||
ca := u.FindClientAssets(tc.target)
|
||||
if ca.InstallerURL != tc.wantInstaller || ca.SignatureURL != tc.wantSig {
|
||||
t.Errorf("FindClientAssets(%q) = {%q, %q}, want {%q, %q}",
|
||||
tc.target, ca.InstallerURL, ca.SignatureURL, tc.wantInstaller, tc.wantSig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +71,7 @@ func TestFindClientAssets_NoMatchingAssets(t *testing.T) {
|
||||
}
|
||||
u.mu.Unlock()
|
||||
|
||||
ca := u.FindClientAssets()
|
||||
ca := u.FindClientAssets("windows-x86_64-nsis")
|
||||
if ca.InstallerURL != "" || ca.SignatureURL != "" {
|
||||
t.Error("expected empty ClientAssets when no NSIS assets")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testHash(seed string) string {
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// multiAssetManifest mirrors the exact JSON the release workflow generates:
|
||||
// legacy top-level fields bind the Windows binary (kept so already-deployed
|
||||
// servers, which only understand the single-asset schema, can still verify)
|
||||
// and the assets list binds every OS.
|
||||
func multiAssetManifest(exeHash, linuxHash string) []byte {
|
||||
return fmt.Appendf(nil,
|
||||
`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}`,
|
||||
exeHash, exeHash, linuxHash)
|
||||
}
|
||||
|
||||
func TestVerifyReleaseManifest_MultiAssetSelectsLinuxEntry(t *testing.T) {
|
||||
u, key := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := multiAssetManifest(testHash("exe"), testHash("linux"))
|
||||
sig := signTestAsset(t, key, manifest)
|
||||
|
||||
got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver-linux-amd64.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyReleaseManifest: %v", err)
|
||||
}
|
||||
if got.Asset != "chatserver-linux-amd64.tar.gz" || got.SHA256 != testHash("linux") {
|
||||
t.Errorf("resolved binding = {%s, %s}, want linux entry", got.Asset, got.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReleaseManifest_MultiAssetSelectsWindowsEntry(t *testing.T) {
|
||||
u, key := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := multiAssetManifest(testHash("exe"), testHash("linux"))
|
||||
sig := signTestAsset(t, key, manifest)
|
||||
|
||||
got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyReleaseManifest: %v", err)
|
||||
}
|
||||
if got.Asset != "chatserver.exe" || got.SHA256 != testHash("exe") {
|
||||
t.Errorf("resolved binding = {%s, %s}, want windows entry", got.Asset, got.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReleaseManifest_MultiAssetUnknownAssetFails(t *testing.T) {
|
||||
u, key := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := multiAssetManifest(testHash("exe"), testHash("linux"))
|
||||
sig := signTestAsset(t, key, manifest)
|
||||
|
||||
if _, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "other.bin"); err == nil {
|
||||
t.Error("expected error for asset the manifest does not bind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReleaseManifest_MultiAssetBadChecksumFails(t *testing.T) {
|
||||
u, key := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := fmt.Appendf(nil,
|
||||
`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver-linux-amd64.tar.gz","sha256":"not-a-hash"}]}`,
|
||||
testHash("exe"))
|
||||
sig := signTestAsset(t, key, manifest)
|
||||
|
||||
if _, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver-linux-amd64.tar.gz"); err == nil {
|
||||
t.Error("expected error for invalid checksum in assets entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReleaseManifest_LegacySingleAssetStillVerifies(t *testing.T) {
|
||||
u, key := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := fmt.Appendf(nil,
|
||||
`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"%s"}`, testHash("exe"))
|
||||
sig := signTestAsset(t, key, manifest)
|
||||
|
||||
got, err := u.VerifyReleaseManifest(manifest, sig, "v1.0.0", "chatserver.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyReleaseManifest: %v", err)
|
||||
}
|
||||
if got.SHA256 != testHash("exe") {
|
||||
t.Errorf("SHA256 = %s, want legacy hash", got.SHA256)
|
||||
}
|
||||
}
|
||||
+61
-20
@@ -76,8 +76,19 @@ type UpdateInfo struct {
|
||||
|
||||
type releaseManifest struct {
|
||||
Version string `json:"version"`
|
||||
Asset string `json:"asset"`
|
||||
SHA256 string `json:"sha256"`
|
||||
// Asset/SHA256 bind a single artifact. Releases before the multi-OS
|
||||
// manifest bound only this pair; newer releases keep it pointing at the
|
||||
// Windows binary so already-deployed servers can still verify and update.
|
||||
Asset string `json:"asset"`
|
||||
SHA256 string `json:"sha256"`
|
||||
// Assets binds every server artifact the release ships (one per OS).
|
||||
Assets []releaseManifestAsset `json:"assets,omitempty"`
|
||||
}
|
||||
|
||||
// releaseManifestAsset is one artifact binding in a multi-OS release manifest.
|
||||
type releaseManifestAsset struct {
|
||||
Asset string `json:"asset"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// Asset is a simplified release asset with name and download URL.
|
||||
@@ -525,26 +536,37 @@ func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expe
|
||||
return releaseManifest{}, fmt.Errorf("parsing release manifest: %w", err)
|
||||
}
|
||||
manifest.Version = ensureVPrefix(strings.TrimSpace(manifest.Version))
|
||||
manifest.Asset = strings.TrimSpace(manifest.Asset)
|
||||
manifest.SHA256 = strings.ToLower(strings.TrimSpace(manifest.SHA256))
|
||||
|
||||
if manifest.Version == "" || manifest.Asset == "" || manifest.SHA256 == "" {
|
||||
if manifest.Version == "v" {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest is missing required fields")
|
||||
}
|
||||
if manifest.Version != ensureVPrefix(expectedVersion) {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest version %q does not match release %q", manifest.Version, ensureVPrefix(expectedVersion))
|
||||
}
|
||||
if manifest.Asset != expectedAsset {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest asset %q does not match expected asset %q", manifest.Asset, expectedAsset)
|
||||
}
|
||||
if len(manifest.SHA256) != sha256.Size*2 {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", manifest.Asset)
|
||||
}
|
||||
if _, err := hex.DecodeString(manifest.SHA256); err != nil {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", manifest.Asset, err)
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
// Candidate bindings: the per-OS assets list plus the legacy single-asset
|
||||
// pair (the only binding manifests from older releases carry).
|
||||
candidates := append([]releaseManifestAsset{}, manifest.Assets...)
|
||||
candidates = append(candidates, releaseManifestAsset{Asset: manifest.Asset, SHA256: manifest.SHA256})
|
||||
for _, c := range candidates {
|
||||
asset := strings.TrimSpace(c.Asset)
|
||||
if asset == "" || asset != expectedAsset {
|
||||
continue
|
||||
}
|
||||
sum := strings.ToLower(strings.TrimSpace(c.SHA256))
|
||||
if len(sum) != sha256.Size*2 {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s has invalid length", asset)
|
||||
}
|
||||
if _, err := hex.DecodeString(sum); err != nil {
|
||||
return releaseManifest{}, fmt.Errorf("release manifest checksum for %s is invalid: %w", asset, err)
|
||||
}
|
||||
// Normalize the returned binding to the matched entry so callers can
|
||||
// keep reading manifest.Asset/manifest.SHA256 regardless of schema.
|
||||
manifest.Asset = asset
|
||||
manifest.SHA256 = sum
|
||||
return manifest, nil
|
||||
}
|
||||
return releaseManifest{}, fmt.Errorf("release manifest does not bind expected asset %q", expectedAsset)
|
||||
}
|
||||
|
||||
// VerifySignature checks whether the detached minisign signature matches the
|
||||
@@ -720,9 +742,28 @@ func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) {
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes))
|
||||
}
|
||||
|
||||
// FindClientAssets scans the cached release assets for the Tauri NSIS
|
||||
// installer zip and its Ed25519 signature file.
|
||||
func (u *Updater) FindClientAssets() ClientAssets {
|
||||
// clientAssetSuffixByTarget maps a Tauri updater target
|
||||
// ("{os}-{arch}-{installer}") to the release asset suffix for that platform's
|
||||
// updater artifact. The matching signature asset is the same suffix plus
|
||||
// ".sig". Targets without a published updater artifact are absent — notably
|
||||
// linux-*-deb: the release ships .deb packages but no signed deb updater
|
||||
// artifact, and serving the AppImage archive instead would make the plugin's
|
||||
// install_deb reject every update.
|
||||
var clientAssetSuffixByTarget = map[string]string{
|
||||
"windows-x86_64-nsis": "_x64-setup.nsis.zip",
|
||||
"linux-x86_64-appimage": "_amd64.AppImage.tar.gz",
|
||||
"linux-aarch64-appimage": "_aarch64.AppImage.tar.gz",
|
||||
}
|
||||
|
||||
// FindClientAssets scans the cached release assets for the client updater
|
||||
// artifact and its signature matching the given Tauri updater target
|
||||
// (e.g. "windows-x86_64-nsis"). Unknown targets return empty ClientAssets.
|
||||
func (u *Updater) FindClientAssets(target string) ClientAssets {
|
||||
suffix, ok := clientAssetSuffixByTarget[target]
|
||||
if !ok {
|
||||
return ClientAssets{}
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
@@ -733,9 +774,9 @@ func (u *Updater) FindClientAssets() ClientAssets {
|
||||
var ca ClientAssets
|
||||
for _, a := range u.cache.Assets {
|
||||
switch {
|
||||
case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip.sig"):
|
||||
case strings.HasSuffix(a.Name, suffix+".sig"):
|
||||
ca.SignatureURL = a.DownloadURL
|
||||
case strings.HasSuffix(a.Name, "_x64-setup.nsis.zip"):
|
||||
case strings.HasSuffix(a.Name, suffix):
|
||||
ca.InstallerURL = a.DownloadURL
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1098,7 +1098,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `target` | string | Platform target (e.g., `windows-x86_64`) |
|
||||
| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis`, `linux-x86_64-appimage`, `linux-aarch64-appimage`). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb`) get 204. |
|
||||
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
|
||||
|
||||
#### Response 200 OK (update available)
|
||||
@@ -1109,7 +1109,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new
|
||||
"notes": "## What's Changed\n...",
|
||||
"pub_date": "2026-03-28T00:00:00Z",
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"windows-x86_64-nsis": {
|
||||
"signature": "base64-encoded-signature",
|
||||
"url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
|
||||
}
|
||||
@@ -1119,7 +1119,7 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new
|
||||
|
||||
#### Response 204 No Content
|
||||
|
||||
Client is already up-to-date.
|
||||
Client is already up-to-date, or no client build is published for `target`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user