From 32c976beb84deddad2e5e2c544f32bae048ed766 Mon Sep 17 00:00:00 2001 From: AB Date: Mon, 7 Sep 2026 12:36:48 +0300 Subject: [PATCH] Added self updater --- .github/workflows/release.yml | 7 +- Cargo.lock | 86 +++++++ Cargo.toml | 14 +- README.md | 18 ++ src/app/event.rs | 3 + src/app/mod.rs | 63 +++++ src/app/state.rs | 9 +- src/app/update.rs | 20 ++ src/app/update_tests.rs | 21 ++ src/main.rs | 1 + src/ui/federation.rs | 84 ++++++- src/updater.rs | 432 ++++++++++++++++++++++++++++++++++ 12 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 src/updater.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b130136..2f87264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,12 @@ jobs: run: | set -euo pipefail - gh release create "$RELEASE_TAG" artifacts/*/* \ + # Hash archive bytes; manifest entries use the release asset basenames. + for archive in artifacts/*/*; do + (cd "$(dirname "$archive")" && sha256sum "$(basename "$archive")") + done > SHA256SUMS + + gh release create "$RELEASE_TAG" artifacts/*/* SHA256SUMS \ --title "furumi ${RELEASE_TAG}" \ --notes "Release ${RELEASE_TAG}" \ --verify-tag diff --git a/Cargo.lock b/Cargo.lock index 469ab99..f4471fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -1182,6 +1191,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1666,6 +1686,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "furumi-library" version = "0.1.0" @@ -1694,24 +1724,32 @@ dependencies = [ "crokey", "crossterm", "directories", + "flate2", + "fs2", "furumi-library", "futures-util", "image", "libc", "lofty", "music-dht", + "object", "ratatui", "reqwest 0.12.28", "rhai", "rodio", "rusqlite", "rustfft", + "rustls", "rusty-opus", + "self-replace", + "semver", "serde", "serde_json", "sha2 0.10.9", "souvlaki", "symphonia", + "tar", + "tempfile", "thiserror 2.0.20", "tokio", "toml", @@ -1720,6 +1758,7 @@ dependencies = [ "tract-onnx", "unicode-width", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -3862,6 +3901,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "ogg_pager" version = "0.7.2" @@ -4792,6 +4840,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -5181,6 +5230,17 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand 2.5.0", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" @@ -7613,6 +7673,20 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.7" @@ -7625,6 +7699,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index f136c37..885f363 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumi_tui" -version = "0.2.8" +version = "0.2.9" edition = "2024" rust-version = "1.97" description = "A federated P2P player for personal music libraries" @@ -11,6 +11,16 @@ name = "furumi" path = "src/main.rs" [dependencies] +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +semver = "1" +self-replace = "1.5" +tempfile = "3" +fs2 = "0.4" +flate2 = "1" +tar = "0.4" +zip = { version = "4", default-features = false, features = ["deflate"] } +object = { version = "0.36", default-features = false, features = ["read", "std"] } + anyhow = "1.0.102" blake3 = "1" crokey = "1.4.0" @@ -24,7 +34,7 @@ lofty = "0.22" # peers (same protocol as furumi-fd). music-dht = "0.4.1" ratatui = "0.30.1" -reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] } +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream", "blocking"] } rhai = { version = "1", features = ["sync"] } rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } rusty-opus = "0.9.1" diff --git a/README.md b/README.md index 3eb4724..9acdb0d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ Import a music directory from Furumi's command line: Federation, trusted-device pairing, and key bindings are configured directly inside the player. +### Manual updates + +In **Settings → Updates**, select **Check for updates**, then **Install update** +when a newer stable GitHub release is available. Downloads run in the background. +After installation, restart `furumi` to use the new version; playback is not +restarted automatically. Wait for an active update operation to finish before +quitting. + +Updates replace the running executable in its installation directory, which +must be writable by your user. Release archives must include a matching entry +in the release's `SHA256SUMS` asset. Older releases without it cannot be installed +through this feature. The updater checks SHA-256 and the executable's format +and architecture before replacing it. Checksums provide integrity checking, +not publisher signatures. Settings and the local library are preserved. + +There are no automatic startup checks. Only the existing release asset naming +scheme is supported; missing or incompatible platform builds are rejected. + ### Now playing in tmux While Furumi is running, a second invocation can print a cheap, single-line diff --git a/src/app/event.rs b/src/app/event.rs index 5025389..88a0c84 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -10,6 +10,9 @@ use crate::library::models::{ /// the playback engine, imports). Tasks never touch AppState directly. #[derive(Debug)] pub enum AppEvent { + UpdateChecked(Result, String>), + UpdateProgress(String), + UpdateInstalled(Result<(), String>), StatusMessage(String), /// A page of the artists list arrived (or failed). ArtistsLoaded(Result), diff --git a/src/app/mod.rs b/src/app/mod.rs index 6b482d0..76ca644 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -431,6 +431,13 @@ pub async fn run( } if state.should_quit { + // A replacement must finish before the runtime/process is torn down. + if state.updater.busy { + state.should_quit = false; + state.status_message = + Some("Please wait for the update operation to finish".into()); + continue; + } if runtime.plain_text_mode { leave_plain_text_mode()?; runtime.plain_text_mode = false; @@ -1415,6 +1422,31 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { return; } match effect { + Effect::CheckUpdate => { + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(crate::updater::check) + .await + .map_err(|err| format!("update worker failed: {err}")) + .and_then(|result| result.map_err(|err| format!("{err:#}"))); + let _ = tx.send(AppEvent::UpdateChecked(result)); + }); + } + Effect::InstallUpdate(update) => { + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let progress_tx = tx.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::updater::install(&update, |message| { + let _ = progress_tx.send(AppEvent::UpdateProgress(message)); + }) + }) + .await + .map_err(|err| format!("update worker failed: {err}")) + .and_then(|result| result.map_err(|err| format!("{err:#}"))); + let _ = tx.send(AppEvent::UpdateInstalled(result)); + }); + } Effect::PlayCurrent => { play_current(state, runtime); push_media_metadata(state, runtime); @@ -3098,6 +3130,37 @@ fn handle_playback_command( fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) { match event { + AppEvent::UpdateChecked(result) => { + state.updater.busy = false; + state.updater.message = match result { + Ok(Some(update)) => { + let message = format!("v{} available", update.version); + state.updater.available = Some(update); + message + } + Ok(None) => "No newer stable release".into(), + Err(error) => format!("Check failed: {error}"), + }; + state.status_message = Some(state.updater.message.clone()); + } + AppEvent::UpdateProgress(message) => state.updater.message = message, + AppEvent::UpdateInstalled(result) => { + state.updater.busy = false; + state.updater.message = match result { + Ok(()) => { + state.updater.installed = true; + let version = state + .updater + .available + .as_ref() + .map(|u| u.version.as_str()) + .unwrap_or("new version"); + format!("Installed {version}; restart furumi") + } + Err(error) => format!("Update failed: {error}"), + }; + state.status_message = Some(state.updater.message.clone()); + } AppEvent::StatusMessage(message) => state.status_message = Some(message), AppEvent::ListenHistoryLoaded(result) => { state.listen_history = Some(match result { diff --git a/src/app/state.rs b/src/app/state.rs index 25d0221..118cc22 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -926,6 +926,8 @@ impl FedRow { /// the config directory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsRow { + CheckUpdate, + InstallUpdate, MusicDirectory, Similarity(SimilarityRow), Federation(FedRow), @@ -1065,7 +1067,11 @@ pub fn device_status_order(state: &AppState) -> Vec { } pub fn settings_rows(state: &AppState) -> Vec { - let mut rows = vec![SettingsRow::MusicDirectory]; + let mut rows = vec![ + SettingsRow::MusicDirectory, + SettingsRow::CheckUpdate, + SettingsRow::InstallUpdate, + ]; rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity)); rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation)); rows.push(SettingsRow::DeviceName); @@ -1571,6 +1577,7 @@ impl DevicePlaybackState { /// event handlers in the main loop; views render from `&AppState`. #[derive(Debug, Default)] pub struct AppState { + pub updater: crate::updater::State, pub active_tab: Tab, pub should_quit: bool, pub shutting_down: bool, diff --git a/src/app/update.rs b/src/app/update.rs index af5ba62..c3a58b1 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -18,6 +18,8 @@ pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit"; /// owns the Runtime (audio controller, API client). Keeps update() pure. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Effect { + CheckUpdate, + InstallUpdate(crate::updater::Update), /// (Re)start playback of `queue[queue_pos]`. PlayCurrent, TogglePause, @@ -2815,6 +2817,24 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve fn federation_select(state: &mut AppState) -> Option { use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow}; match settings_rows(state).get(state.settings_cursor).copied()? { + SettingsRow::CheckUpdate => { + if state.updater.busy || state.updater.installed { + return None; + } + state.updater.busy = true; + state.updater.available = None; + state.updater.message = "Checking GitHub Releases...".into(); + Some(Effect::CheckUpdate) + } + SettingsRow::InstallUpdate => { + if state.updater.busy || state.updater.installed { + return None; + } + let update = state.updater.available.clone()?; + state.updater.busy = true; + state.updater.message = "Downloading update...".into(); + Some(Effect::InstallUpdate(update)) + } SettingsRow::MusicDirectory => { if state.music_dir_changing { state.status_message = Some("music directory change is already running".into()); diff --git a/src/app/update_tests.rs b/src/app/update_tests.rs index 467581a..219f52e 100644 --- a/src/app/update_tests.rs +++ b/src/app/update_tests.rs @@ -1,6 +1,27 @@ use super::*; use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; +#[test] +fn manual_update_check_is_single_flight_and_disabled_after_install() { + let mut state = AppState::default(); + state.settings_cursor = settings_rows(&state) + .iter() + .position(|row| *row == crate::app::state::SettingsRow::CheckUpdate) + .unwrap(); + assert_eq!(federation_select(&mut state), Some(Effect::CheckUpdate)); + assert!(state.updater.busy); + assert_eq!(federation_select(&mut state), None); + state.updater.busy = false; + state.updater.installed = true; + assert_eq!(federation_select(&mut state), None); + state.updater.installed = false; + state.settings_cursor = settings_rows(&state) + .iter() + .position(|row| *row == crate::app::state::SettingsRow::InstallUpdate) + .unwrap(); + assert_eq!(federation_select(&mut state), None); +} + fn with_artists(n: usize) -> AppState { let mut state = AppState::default(); state.global.artists = (0..n) diff --git a/src/main.rs b/src/main.rs index f40ea1e..f0e7db4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod similarity; mod status; mod streaming; mod ui; +mod updater; mod visualizer; use std::io; diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 0d68083..3cc1ad9 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph}; +use ratatui::widgets::{Block, Paragraph, Wrap}; use super::theme; use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows}; @@ -33,7 +33,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { } let rows_height = - (settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16; + (settings_rows(state).len() + 15 + device_presence_sections(state).len()) as u16; let [rows_area, _, status_area] = Layout::vertical([ Constraint::Length(rows_height.min(inner.height)), Constraint::Length(1), @@ -88,6 +88,48 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { y = y.saturating_add(1); + draw_section(frame, area, state, &mut y, "Updates"); + draw_row_enabled( + frame, + area, + state, + &mut y, + cursor, + state.settings_cursor, + "Check for updates", + format!("v{} | enter", env!("CARGO_PKG_VERSION")), + !state.updater.busy && !state.updater.installed, + ); + cursor += 1; + draw_row_enabled( + frame, + area, + state, + &mut y, + cursor, + state.settings_cursor, + "Install update", + state + .updater + .available + .as_ref() + .map(|update| format!("v{} | enter", update.version)) + .unwrap_or_else(|| "check for updates first".into()), + state.updater.available.is_some() && !state.updater.busy && !state.updater.installed, + ); + cursor += 1; + if y < area.bottom() { + let height = 3.min(area.bottom() - y); + frame.render_widget( + Paragraph::new(state.updater.message.as_str()) + .style(theme::dim()) + .wrap(Wrap { trim: true }), + Rect::new(area.x, y, area.width, height), + ); + y += height; + } + y = y.saturating_add(1); + draw_section(frame, area, state, &mut y, "Similarity Search"); let similarity = &state.similarity.settings; for row in SimilarityRow::ALL { @@ -1578,3 +1620,41 @@ fn relative_time_label(value_ms: Option, now_ms: i64) -> String { format!("{}d ago", seconds / 60 / 60 / 24) } } + +#[cfg(test)] +mod update_ui_tests { + use super::*; + + #[test] + fn update_controls_and_existing_sections_render_in_both_layouts() { + for width in [80, 160] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 55)).unwrap(); + let mut state = AppState::default(); + state.updater.message = "No newer stable release".into(); + terminal + .draw(|frame| draw(frame, frame.area(), &state)) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + for expected in [ + "Music save directory", + "Check for updates", + "Install update", + "No newer stable release", + "Similarity Search", + "Federation", + ] { + assert!( + text.contains(expected), + "missing {expected} at width {width}" + ); + } + } + } +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..f9afc50 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,432 @@ +//! Manual portable-binary updates. All network/disk work runs off the UI thread. +use std::{ + fs::File, + io::{Read, Write}, + path::Path, + time::Duration, +}; + +use anyhow::{Context, Result, bail, ensure}; +use object::Object; +use reqwest::blocking::Client; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +const REPOSITORY: &str = "https://api.github.com/repos/house-of-vanity/furumi_tui"; +const MAX_ARCHIVE: u64 = 512 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Update { + pub version: String, + asset: Asset, + checksums: Asset, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +#[derive(Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Default)] +pub struct State { + pub busy: bool, + pub installed: bool, + pub available: Option, + pub message: String, +} + +fn client() -> Result { + // Reuse the ring backend already used by federation; respect an existing provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + Ok(Client::builder() + .user_agent(concat!("furumi/", env!("CARGO_PKG_VERSION"))) + .https_only(true) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build()?) +} + +fn read_limited(mut reader: impl Read, limit: u64) -> Result> { + let mut data = Vec::new(); + reader.by_ref().take(limit + 1).read_to_end(&mut data)?; + ensure!(data.len() as u64 <= limit, "download exceeds size limit"); + Ok(data) +} + +fn select_release(release: Release, current: &str, os: &str, arch: &str) -> Result> { + let version = release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name); + let next = Version::parse(version).context("invalid release version")?; + if release.draft + || release.prerelease + || !next.pre.is_empty() + || next <= Version::parse(current)? + { + return Ok(None); + } + let platform = match os { + "linux" => "linux", + "macos" => "macos", + "windows" => "windows", + _ => bail!("self-update is unsupported on {os}"), + }; + let extension = if os == "windows" { "zip" } else { "tar.gz" }; + let name = format!("furumi-{platform}-{arch}-{version}.{extension}"); + let find = |name: &str| -> Result { + let matches: Vec<_> = release + .assets + .iter() + .filter(|asset| asset.name == name) + .collect(); + ensure!(matches.len() == 1, "release has no unique {name} asset"); + Ok(matches[0].clone()) + }; + Ok(Some(Update { + version: version.to_owned(), + asset: find(&name)?, + checksums: find("SHA256SUMS")?, + })) +} + +pub fn check() -> Result> { + let response = client()? + .get(format!("{REPOSITORY}/releases/latest")) + .timeout(Duration::from_secs(20)) + .send()?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let release = serde_json::from_slice(&read_limited( + response.error_for_status()?, + 2 * 1024 * 1024, + )?)?; + select_release( + release, + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) +} + +fn checksum(text: &str, name: &str) -> Result { + let mut found = None; + for line in text.lines() { + let Some((hash, filename)) = line.split_once(' ') else { + continue; + }; + if filename.trim_start().trim_start_matches('*') != name { + continue; + } + ensure!(found.is_none(), "duplicate checksum for {name}"); + ensure!( + hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid SHA-256 for {name}" + ); + found = Some(hash.to_ascii_lowercase()); + } + found.context("release does not contain a checksum for the selected archive") +} + +fn extract(archive: &Path, name: &str, output: &mut File) -> Result<()> { + let binary = if cfg!(windows) { + "furumi.exe" + } else { + "furumi" + }; + let root = name + .strip_suffix(".tar.gz") + .or_else(|| name.strip_suffix(".zip")) + .context("unsupported archive")?; + // Existing release archives omit the version in their inner directory. + let root = root.rsplit_once('-').context("invalid archive name")?.0; + let expected = format!("{root}/{binary}"); + let mut count = 0; + if name.ends_with(".zip") { + let mut archive = zip::ZipArchive::new(File::open(archive)?)?; + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + if entry.name() != expected { + continue; + } + ensure!( + entry.is_file() && !entry.is_symlink(), + "binary is not a regular file" + ); + count += 1; + ensure!(count == 1, "duplicate binary in archive"); + output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?; + } + } else { + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(File::open(archive)?)); + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.as_ref() != Path::new(&expected) { + continue; + } + ensure!( + entry.header().entry_type().is_file(), + "binary is not a regular file" + ); + count += 1; + ensure!(count == 1, "duplicate binary in archive"); + output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?; + } + } + ensure!(count == 1, "archive does not contain {expected}"); + output.sync_all()?; + Ok(()) +} + +fn validate_binary(current: &[u8], candidate: &[u8]) -> Result<()> { + let current = object::File::parse(current).context("cannot inspect installed binary")?; + let candidate = object::File::parse(candidate).context("invalid downloaded binary")?; + ensure!( + candidate.kind() == object::ObjectKind::Executable + || candidate.kind() == object::ObjectKind::Dynamic, + "download is not an executable" + ); + ensure!( + candidate.format() == current.format() + && candidate.architecture() == current.architecture() + && candidate.is_64() == current.is_64() + && candidate.is_little_endian() == current.is_little_endian(), + "downloaded binary has incompatible platform or architecture" + ); + Ok(()) +} + +pub fn install(update: &Update, mut progress: impl FnMut(String)) -> Result<()> { + let exe = std::env::current_exe()?.canonicalize()?; + let parent = exe.parent().context("executable has no parent directory")?; + let lock = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(parent.join(".furumi-update.lock")) + .context("cannot write to installation directory")?; + fs2::FileExt::try_lock_exclusive(&lock).context("another furumi instance is updating")?; + // Keep staging on the destination filesystem and never overwrite a running image. + let staging = tempfile::Builder::new() + .prefix(".furumi-update-") + .tempdir_in(parent) + .context("cannot create update staging directory")?; + let client = client()?; + let sums = read_limited( + client + .get(&update.checksums.browser_download_url) + .send()? + .error_for_status()?, + 1024 * 1024, + )?; + let expected = checksum(std::str::from_utf8(&sums)?, &update.asset.name)?; + let mut response = client + .get(&update.asset.browser_download_url) + .send()? + .error_for_status()?; + let total = response.content_length(); + ensure!( + total.is_none_or(|size| size <= MAX_ARCHIVE), + "archive exceeds size limit" + ); + let archive = staging.path().join("download"); + let mut file = File::create(&archive)?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut downloaded = 0u64; + let mut reported = u64::MAX; + loop { + let count = response.read(&mut buffer)?; + if count == 0 { + break; + } + downloaded += count as u64; + ensure!(downloaded <= MAX_ARCHIVE, "archive exceeds size limit"); + file.write_all(&buffer[..count])?; + hash.update(&buffer[..count]); + let mb = downloaded / (1024 * 1024); + if mb != reported { + progress(format!("Downloading: {mb} MiB")); + reported = mb; + } + } + file.sync_all()?; + drop(file); + ensure!( + format!("{:x}", hash.finalize()) == expected, + "SHA-256 mismatch; update was not installed" + ); + progress("Verifying binary...".into()); + let binary = staging.path().join(if cfg!(windows) { + "furumi.exe" + } else { + "furumi" + }); + let mut output = File::create(&binary)?; + extract(&archive, &update.asset.name, &mut output)?; + drop(output); + validate_binary(&std::fs::read(&exe)?, &std::fs::read(&binary)?)?; + std::fs::set_permissions(&binary, std::fs::metadata(&exe)?.permissions())?; + progress("Installing...".into()); + self_replace::self_replace(&binary).context("could not replace executable")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn https_client_can_be_constructed() { + client().unwrap(); + } + + #[test] + fn extracts_only_expected_binary_from_release_archives() { + let temp = tempfile::tempdir().unwrap(); + let binary = if cfg!(windows) { + "furumi.exe" + } else { + "furumi" + }; + let expected = format!("furumi-windows-x86_64/{binary}"); + let payload = b"test executable"; + let zip_path = temp.path().join("test.zip"); + let mut zip = zip::ZipWriter::new(File::create(&zip_path).unwrap()); + zip.start_file("README.md", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"readme").unwrap(); + zip.start_file(&expected, zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(payload).unwrap(); + zip.finish().unwrap(); + let output = temp.path().join("output"); + extract( + &zip_path, + "furumi-windows-x86_64-0.1.6.zip", + &mut File::create(&output).unwrap(), + ) + .unwrap(); + assert_eq!(std::fs::read(&output).unwrap(), payload); + assert!( + extract( + &zip_path, + "furumi-linux-x86_64-0.1.6.zip", + &mut File::create(&output).unwrap() + ) + .is_err() + ); + + let tar_path = temp.path().join("test.tar.gz"); + let gzip = flate2::write::GzEncoder::new( + File::create(&tar_path).unwrap(), + flate2::Compression::default(), + ); + let mut tar = tar::Builder::new(gzip); + let mut header = tar::Header::new_gnu(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, &expected, payload.as_slice()) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); + extract( + &tar_path, + "furumi-windows-x86_64-0.1.6.tar.gz", + &mut File::create(&output).unwrap(), + ) + .unwrap(); + assert_eq!(std::fs::read(&output).unwrap(), payload); + } + + #[test] + fn binary_validation_rejects_wrong_architecture() { + let current = std::fs::read(std::env::current_exe().unwrap()).unwrap(); + let mut other = current.clone(); + // Change only the machine field, preserving an otherwise valid executable. + if current.starts_with(b"MZ") { + let pe = u32::from_le_bytes(current[0x3c..0x40].try_into().unwrap()) as usize; + let machine: u16 = if current[pe + 4..pe + 6] == [0x64, 0x86] { + 0xaa64 + } else { + 0x8664 + }; + other[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes()); + } else if current.starts_with(b"\x7fELF") { + let machine: u16 = if current[18..20] == [62, 0] { 183 } else { 62 }; + other[18..20].copy_from_slice(&machine.to_le_bytes()); + } else if current.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) { + let cpu: u32 = if current[4] == 7 { + 0x0100000c + } else { + 0x01000007 + }; + other[4..8].copy_from_slice(&cpu.to_le_bytes()); + } else { + panic!("unsupported test executable format"); + } + assert!(validate_binary(¤t, &other).is_err()); + } + #[test] + fn checksums_require_exact_unique_valid_entry() { + let hash = "ab".repeat(32); + assert_eq!( + checksum(&format!("{hash} app.zip\n"), "app.zip").unwrap(), + hash + ); + assert!(checksum(&format!("{hash} other.zip"), "app.zip").is_err()); + assert!(checksum("broken app.zip", "app.zip").is_err()); + assert!(checksum(&format!("{hash} app.zip\n{hash} app.zip"), "app.zip").is_err()); + } + #[test] + fn release_selection_respects_semver_and_assets() { + let release = |version: &str| Release { + tag_name: version.into(), + draft: false, + prerelease: false, + assets: vec![ + Asset { + name: "furumi-linux-x86_64-0.1.10.tar.gz".into(), + browser_download_url: String::new(), + }, + Asset { + name: "SHA256SUMS".into(), + browser_download_url: String::new(), + }, + ], + }; + assert!( + select_release(release("v0.1.10"), "0.1.9", "linux", "x86_64") + .unwrap() + .is_some() + ); + assert!( + select_release(release("v0.1.8"), "0.1.9", "linux", "x86_64") + .unwrap() + .is_none() + ); + assert!( + select_release(release("v0.2.0-beta.1"), "0.1.9", "linux", "x86_64") + .unwrap() + .is_none() + ); + assert!(select_release(release("v0.1.10"), "0.1.9", "linux", "aarch64").is_err()); + } + #[test] + fn binary_validation_rejects_corrupt_download() { + let current = std::fs::read(std::env::current_exe().unwrap()).unwrap(); + validate_binary(¤t, ¤t).unwrap(); + assert!(validate_binary(¤t, b"not a binary").is_err()); + } +}