spool/bootstrap/
updater.rs1use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use std::path::Path;
26use std::time::Duration;
27
28use super::layout::SpoolLayout;
29use super::state::{BootstrapState, ServiceVersion};
30
31const RELEASES_API: &str = "https://api.github.com/repos/lukylong/Spool/releases/latest";
32const USER_AGENT: &str = concat!("spool-updater/", env!("CARGO_PKG_VERSION"));
33
34#[derive(Debug, Clone, Deserialize)]
36struct ReleaseAsset {
37 name: String,
38 browser_download_url: String,
39 size: u64,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43struct ReleaseMeta {
44 tag_name: String,
45 name: Option<String>,
46 body: Option<String>,
47 assets: Vec<ReleaseAsset>,
48 #[serde(default)]
49 prerelease: bool,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct UpdateCheckReport {
55 pub current_version: Option<String>,
56 pub latest_version: String,
57 pub has_update: bool,
58 pub release_notes: Option<String>,
59 pub download_url: Option<String>,
60 pub asset_size: Option<u64>,
61 pub is_prerelease: bool,
62}
63
64#[derive(Debug, Clone, Serialize)]
66pub struct UpdateApplyReport {
67 pub success: bool,
68 pub from_version: Option<String>,
69 pub to_version: String,
70 pub bytes_downloaded: u64,
71 pub messages: Vec<String>,
72}
73
74pub fn check_for_update(layout: &SpoolLayout) -> Result<UpdateCheckReport> {
77 let state = BootstrapState::load(&layout.version_file())
78 .context("loading bootstrap state for update check")?;
79 let current = state.service.as_ref().map(|s| s.version.clone());
80 check_for_update_against(¤t)
81}
82
83fn check_for_update_against(current: &Option<String>) -> Result<UpdateCheckReport> {
84 let release = fetch_latest_release()?;
85 let latest_version = strip_v_prefix(&release.tag_name).to_string();
86 let asset_name = expected_asset_name();
87 let asset = release.assets.iter().find(|a| a.name == asset_name);
88
89 let has_update = match current {
90 Some(curr) => version_is_newer(&latest_version, curr),
91 None => true,
92 };
93
94 Ok(UpdateCheckReport {
95 current_version: current.clone(),
96 latest_version,
97 has_update,
98 release_notes: release.body.or(release.name),
99 download_url: asset.map(|a| a.browser_download_url.clone()),
100 asset_size: asset.map(|a| a.size),
101 is_prerelease: release.prerelease,
102 })
103}
104
105pub fn apply_update(layout: &SpoolLayout) -> Result<UpdateApplyReport> {
107 let mut messages = Vec::new();
108 let mut state = BootstrapState::load(&layout.version_file())
109 .context("loading bootstrap state for update apply")?;
110 let from_version = state.service.as_ref().map(|s| s.version.clone());
111
112 let release = fetch_latest_release().context("fetching latest release metadata")?;
113 let latest_version = strip_v_prefix(&release.tag_name).to_string();
114
115 let asset_name = expected_asset_name();
116 let asset = release
117 .assets
118 .iter()
119 .find(|a| a.name == asset_name)
120 .ok_or_else(|| {
121 anyhow::anyhow!(
122 "no asset matching '{asset_name}' in release {}",
123 release.tag_name
124 )
125 })?;
126
127 messages.push(format!("downloading {} ({} bytes)", asset.name, asset.size));
128 let tarball_bytes = download_to_memory(&asset.browser_download_url)
129 .with_context(|| format!("downloading {}", asset.browser_download_url))?;
130 let bytes_downloaded = tarball_bytes.len() as u64;
131
132 let bin_new = layout.root().join("bin.new");
134 let bin_old = layout.root().join("bin.old");
135 let bin_dir = layout.bin_dir();
136
137 if bin_new.exists() {
138 std::fs::remove_dir_all(&bin_new).ok();
139 }
140 std::fs::create_dir_all(&bin_new).with_context(|| format!("creating {}", bin_new.display()))?;
141
142 extract_tarball(&tarball_bytes, &bin_new)
143 .with_context(|| format!("extracting tarball to {}", bin_new.display()))?;
144 messages.push(format!("extracted to {}", bin_new.display()));
145
146 if bin_old.exists() {
148 std::fs::remove_dir_all(&bin_old).ok();
149 }
150 if bin_dir.exists() {
151 std::fs::rename(&bin_dir, &bin_old)
152 .with_context(|| format!("renaming {} → {}", bin_dir.display(), bin_old.display()))?;
153 }
154 std::fs::rename(&bin_new, &bin_dir)
155 .with_context(|| format!("renaming {} → {}", bin_new.display(), bin_dir.display()))?;
156 if bin_old.exists() {
157 let _ = std::fs::remove_dir_all(&bin_old);
158 }
159 messages.push("atomic swap complete".to_string());
160
161 state.service = Some(ServiceVersion {
162 version: latest_version.clone(),
163 released_at: chrono_now(),
164 });
165 state
166 .save(&layout.version_file())
167 .context("persisting updated state")?;
168 messages.push(format!("version.json updated to {latest_version}"));
169
170 Ok(UpdateApplyReport {
171 success: true,
172 from_version,
173 to_version: latest_version,
174 bytes_downloaded,
175 messages,
176 })
177}
178
179fn fetch_latest_release() -> Result<ReleaseMeta> {
180 let client = reqwest::blocking::Client::builder()
181 .user_agent(USER_AGENT)
182 .timeout(Duration::from_secs(15))
183 .build()
184 .context("building HTTP client")?;
185 let response = client
186 .get(RELEASES_API)
187 .send()
188 .context("GET /releases/latest")?;
189 let status = response.status();
190 if !status.is_success() {
191 bail!("GitHub API returned status {status}");
192 }
193 response
194 .json::<ReleaseMeta>()
195 .context("parsing release JSON")
196}
197
198fn download_to_memory(url: &str) -> Result<Vec<u8>> {
199 let client = reqwest::blocking::Client::builder()
200 .user_agent(USER_AGENT)
201 .timeout(Duration::from_secs(120))
202 .build()?;
203 let response = client.get(url).send()?;
204 let status = response.status();
205 if !status.is_success() {
206 bail!("download returned status {status}");
207 }
208 let bytes = response.bytes()?;
209 Ok(bytes.to_vec())
210}
211
212fn extract_tarball(bytes: &[u8], target: &Path) -> Result<()> {
213 let cursor = std::io::Cursor::new(bytes);
214 let decoder = flate2::read::GzDecoder::new(cursor);
215 let mut archive = tar::Archive::new(decoder);
216 archive.unpack(target)?;
217
218 #[cfg(unix)]
220 {
221 use std::os::unix::fs::PermissionsExt;
222 for entry in std::fs::read_dir(target)? {
223 let entry = entry?;
224 let path = entry.path();
225 if path.is_file() {
226 let mut perms = std::fs::metadata(&path)?.permissions();
227 perms.set_mode(0o755);
228 std::fs::set_permissions(&path, perms)?;
229 }
230 }
231 }
232
233 Ok(())
234}
235
236fn expected_asset_name() -> String {
239 let platform = if cfg!(target_os = "macos") {
240 if cfg!(target_arch = "aarch64") {
241 "macos-arm64"
242 } else {
243 "macos-intel"
244 }
245 } else if cfg!(target_os = "linux") {
246 if cfg!(target_arch = "aarch64") {
247 "linux-arm64"
248 } else {
249 "linux-x86_64"
250 }
251 } else if cfg!(target_os = "windows") {
252 "windows-x86_64"
253 } else {
254 "unknown"
255 };
256 format!("spool-{platform}.tar.gz")
257}
258
259fn strip_v_prefix(tag: &str) -> &str {
260 tag.strip_prefix('v').unwrap_or(tag)
261}
262
263fn version_is_newer(latest: &str, current: &str) -> bool {
268 let parse = |s: &str| -> Vec<u32> {
269 s.split(|c: char| !c.is_ascii_digit() && c != '.')
270 .next()
271 .unwrap_or("")
272 .split('.')
273 .filter_map(|p| p.parse::<u32>().ok())
274 .collect()
275 };
276 let l = parse(latest);
277 let c = parse(current);
278 let max_len = l.len().max(c.len());
279 for i in 0..max_len {
280 let li = l.get(i).copied().unwrap_or(0);
281 let ci = c.get(i).copied().unwrap_or(0);
282 if li > ci {
283 return true;
284 }
285 if li < ci {
286 return false;
287 }
288 }
289 false
290}
291
292fn chrono_now() -> String {
293 use std::time::{SystemTime, UNIX_EPOCH};
294 SystemTime::now()
295 .duration_since(UNIX_EPOCH)
296 .map(|d| d.as_secs().to_string())
297 .unwrap_or_else(|_| "0".to_string())
298}
299
300#[allow(dead_code)]
303pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
304 use sha2::{Digest, Sha256};
305 let mut hasher = Sha256::new();
306 hasher.update(bytes);
307 let digest = hasher.finalize();
308 digest.iter().map(|b| format!("{b:02x}")).collect()
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn version_is_newer_compares_numerically() {
317 assert!(version_is_newer("0.2.0", "0.1.9"));
318 assert!(version_is_newer("0.1.10", "0.1.9"));
319 assert!(version_is_newer("1.0.0", "0.99.99"));
320 assert!(!version_is_newer("0.1.0", "0.1.0"));
321 assert!(!version_is_newer("0.1.0", "0.1.1"));
322 }
323
324 #[test]
325 fn version_is_newer_strips_pre_release_suffix() {
326 assert!(version_is_newer("0.1.2", "0.1.1-alpha.1"));
328 assert!(!version_is_newer("0.1.2-alpha.1", "0.1.2"));
329 }
330
331 #[test]
332 fn strip_v_prefix_handles_both_forms() {
333 assert_eq!(strip_v_prefix("v0.1.2"), "0.1.2");
334 assert_eq!(strip_v_prefix("0.1.2"), "0.1.2");
335 assert_eq!(strip_v_prefix(""), "");
336 }
337
338 #[test]
339 fn expected_asset_name_is_platform_specific() {
340 let name = expected_asset_name();
341 assert!(name.starts_with("spool-"));
342 assert!(name.ends_with(".tar.gz"));
343 }
344
345 #[test]
346 fn sha256_hex_is_64_chars() {
347 let hex = sha256_hex(b"hello");
348 assert_eq!(hex.len(), 64);
349 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
350 }
351}