use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
use super::layout::SpoolLayout;
use super::state::{BootstrapState, ServiceVersion};
const RELEASES_API: &str = "https://api.github.com/repos/lukylong/Spool/releases/latest";
const USER_AGENT: &str = concat!("spool-updater/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, Deserialize)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
size: u64,
}
#[derive(Debug, Clone, Deserialize)]
struct ReleaseMeta {
tag_name: String,
name: Option<String>,
body: Option<String>,
assets: Vec<ReleaseAsset>,
#[serde(default)]
prerelease: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateCheckReport {
pub current_version: Option<String>,
pub latest_version: String,
pub has_update: bool,
pub release_notes: Option<String>,
pub download_url: Option<String>,
pub asset_size: Option<u64>,
pub is_prerelease: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateApplyReport {
pub success: bool,
pub from_version: Option<String>,
pub to_version: String,
pub bytes_downloaded: u64,
pub messages: Vec<String>,
}
pub fn check_for_update(layout: &SpoolLayout) -> Result<UpdateCheckReport> {
let state = BootstrapState::load(&layout.version_file())
.context("loading bootstrap state for update check")?;
let current = state.service.as_ref().map(|s| s.version.clone());
check_for_update_against(¤t)
}
fn check_for_update_against(current: &Option<String>) -> Result<UpdateCheckReport> {
let release = fetch_latest_release()?;
let latest_version = strip_v_prefix(&release.tag_name).to_string();
let asset_name = expected_asset_name();
let asset = release.assets.iter().find(|a| a.name == asset_name);
let has_update = match current {
Some(curr) => version_is_newer(&latest_version, curr),
None => true,
};
Ok(UpdateCheckReport {
current_version: current.clone(),
latest_version,
has_update,
release_notes: release.body.or(release.name),
download_url: asset.map(|a| a.browser_download_url.clone()),
asset_size: asset.map(|a| a.size),
is_prerelease: release.prerelease,
})
}
pub fn apply_update(layout: &SpoolLayout) -> Result<UpdateApplyReport> {
let mut messages = Vec::new();
let mut state = BootstrapState::load(&layout.version_file())
.context("loading bootstrap state for update apply")?;
let from_version = state.service.as_ref().map(|s| s.version.clone());
let release = fetch_latest_release().context("fetching latest release metadata")?;
let latest_version = strip_v_prefix(&release.tag_name).to_string();
let asset_name = expected_asset_name();
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.ok_or_else(|| {
anyhow::anyhow!(
"no asset matching '{asset_name}' in release {}",
release.tag_name
)
})?;
messages.push(format!("downloading {} ({} bytes)", asset.name, asset.size));
let tarball_bytes = download_to_memory(&asset.browser_download_url)
.with_context(|| format!("downloading {}", asset.browser_download_url))?;
let bytes_downloaded = tarball_bytes.len() as u64;
let bin_new = layout.root().join("bin.new");
let bin_old = layout.root().join("bin.old");
let bin_dir = layout.bin_dir();
if bin_new.exists() {
std::fs::remove_dir_all(&bin_new).ok();
}
std::fs::create_dir_all(&bin_new).with_context(|| format!("creating {}", bin_new.display()))?;
extract_tarball(&tarball_bytes, &bin_new)
.with_context(|| format!("extracting tarball to {}", bin_new.display()))?;
messages.push(format!("extracted to {}", bin_new.display()));
if bin_old.exists() {
std::fs::remove_dir_all(&bin_old).ok();
}
if bin_dir.exists() {
std::fs::rename(&bin_dir, &bin_old)
.with_context(|| format!("renaming {} → {}", bin_dir.display(), bin_old.display()))?;
}
std::fs::rename(&bin_new, &bin_dir)
.with_context(|| format!("renaming {} → {}", bin_new.display(), bin_dir.display()))?;
if bin_old.exists() {
let _ = std::fs::remove_dir_all(&bin_old);
}
messages.push("atomic swap complete".to_string());
state.service = Some(ServiceVersion {
version: latest_version.clone(),
released_at: chrono_now(),
});
state
.save(&layout.version_file())
.context("persisting updated state")?;
messages.push(format!("version.json updated to {latest_version}"));
Ok(UpdateApplyReport {
success: true,
from_version,
to_version: latest_version,
bytes_downloaded,
messages,
})
}
fn fetch_latest_release() -> Result<ReleaseMeta> {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(15))
.build()
.context("building HTTP client")?;
let response = client
.get(RELEASES_API)
.send()
.context("GET /releases/latest")?;
let status = response.status();
if !status.is_success() {
bail!("GitHub API returned status {status}");
}
response
.json::<ReleaseMeta>()
.context("parsing release JSON")
}
fn download_to_memory(url: &str) -> Result<Vec<u8>> {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(120))
.build()?;
let response = client.get(url).send()?;
let status = response.status();
if !status.is_success() {
bail!("download returned status {status}");
}
let bytes = response.bytes()?;
Ok(bytes.to_vec())
}
fn extract_tarball(bytes: &[u8], target: &Path) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let decoder = flate2::read::GzDecoder::new(cursor);
let mut archive = tar::Archive::new(decoder);
archive.unpack(target)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for entry in std::fs::read_dir(target)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms)?;
}
}
}
Ok(())
}
fn expected_asset_name() -> String {
let platform = if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
"macos-arm64"
} else {
"macos-intel"
}
} else if cfg!(target_os = "linux") {
if cfg!(target_arch = "aarch64") {
"linux-arm64"
} else {
"linux-x86_64"
}
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
"unknown"
};
format!("spool-{platform}.tar.gz")
}
fn strip_v_prefix(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
fn version_is_newer(latest: &str, current: &str) -> bool {
let parse = |s: &str| -> Vec<u32> {
s.split(|c: char| !c.is_ascii_digit() && c != '.')
.next()
.unwrap_or("")
.split('.')
.filter_map(|p| p.parse::<u32>().ok())
.collect()
};
let l = parse(latest);
let c = parse(current);
let max_len = l.len().max(c.len());
for i in 0..max_len {
let li = l.get(i).copied().unwrap_or(0);
let ci = c.get(i).copied().unwrap_or(0);
if li > ci {
return true;
}
if li < ci {
return false;
}
}
false
}
fn chrono_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[allow(dead_code)]
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
digest.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_newer_compares_numerically() {
assert!(version_is_newer("0.2.0", "0.1.9"));
assert!(version_is_newer("0.1.10", "0.1.9"));
assert!(version_is_newer("1.0.0", "0.99.99"));
assert!(!version_is_newer("0.1.0", "0.1.0"));
assert!(!version_is_newer("0.1.0", "0.1.1"));
}
#[test]
fn version_is_newer_strips_pre_release_suffix() {
assert!(version_is_newer("0.1.2", "0.1.1-alpha.1"));
assert!(!version_is_newer("0.1.2-alpha.1", "0.1.2"));
}
#[test]
fn strip_v_prefix_handles_both_forms() {
assert_eq!(strip_v_prefix("v0.1.2"), "0.1.2");
assert_eq!(strip_v_prefix("0.1.2"), "0.1.2");
assert_eq!(strip_v_prefix(""), "");
}
#[test]
fn expected_asset_name_is_platform_specific() {
let name = expected_asset_name();
assert!(name.starts_with("spool-"));
assert!(name.ends_with(".tar.gz"));
}
#[test]
fn sha256_hex_is_64_chars() {
let hex = sha256_hex(b"hello");
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
}