1use super::github::{GithubRelease, download_asset_bytes, fetch_release};
2use super::{ReleaseChannel, UpgradeResult, parse_release_tag, unix_timestamp_now};
3use crate::config::Config;
4use crate::{platform, version};
5use anyhow::{Context, Result, anyhow, bail};
6use flate2::read::GzDecoder;
7use semver::Version;
8use std::ffi::OsStr;
9use std::path::Path;
10use tar::Archive;
11use tokio::fs;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14struct ReleaseAsset {
15 release_tag: String,
16 target: String,
17 download_url: String,
18}
19
20pub async fn upgrade_to_release(
21 config: &Config,
22 channel: ReleaseChannel,
23 force_install: bool,
24) -> Result<UpgradeResult> {
25 let current = Version::parse(env!("CARGO_PKG_VERSION"))
26 .context("current package version must be valid semver")?;
27 let current_display = version::semver();
28
29 let release = fetch_release(channel).await?;
30 let latest = match channel {
31 ReleaseChannel::Stable => {
32 let latest = parse_release_tag(&release.tag_name)?;
33 let now_secs = unix_timestamp_now()?;
34 let cache_path = config.shine_dir().join(super::UPDATE_CACHE_FILE);
35 super::store_cache_if_possible(&cache_path, &latest, now_secs).await;
36
37 if !force_install && latest <= current {
38 return Ok(UpgradeResult::AlreadyUpToDate {
39 channel,
40 latest: latest.to_string(),
41 });
42 }
43 Some(latest)
44 }
45 ReleaseChannel::Preview => {
46 if preview_release_matches_commit(&release, version::preview_commit()) {
47 return Ok(UpgradeResult::AlreadyUpToDate {
48 channel,
49 latest: current_display.to_string(),
50 });
51 }
52 None
53 }
54 };
55
56 let asset = find_release_asset(
57 &release,
58 channel,
59 std::env::consts::OS,
60 std::env::consts::ARCH,
61 )?;
62 let archive_bytes = download_asset_bytes(&asset.download_url).await?;
63 let current_exe = std::env::current_exe().context("failed to resolve current executable")?;
64 install_downloaded_archive(
65 &archive_bytes,
66 ¤t_exe,
67 platform::current_executable_name(),
68 )
69 .await?;
70 let installed_version = installed_version_label(¤t_exe, &asset.release_tag).await;
71
72 if let Some(latest) = &latest {
73 let now_secs = unix_timestamp_now()?;
74 let cache_path = config.shine_dir().join(super::UPDATE_CACHE_FILE);
75 super::store_cache_if_possible(&cache_path, latest, now_secs).await;
76 }
77
78 Ok(UpgradeResult::Upgraded {
79 channel,
80 previous: current,
81 previous_display: current_display.to_string(),
82 release_tag: asset.release_tag,
83 installed_version,
84 installed_path: current_exe,
85 })
86}
87
88async fn install_downloaded_archive(
89 archive_bytes: &[u8],
90 current_exe: &Path,
91 binary_name: &str,
92) -> Result<()> {
93 let extracted = extract_binary_from_archive(archive_bytes, binary_name)?;
94
95 let parent_dir = current_exe
96 .parent()
97 .context("current executable path must have a parent directory")?;
98 let staged_path = parent_dir.join(format!(".shine-upgrade-{}", uuid::Uuid::new_v4()));
99 let backup_path = parent_dir.join(format!(".shine-backup-{}", uuid::Uuid::new_v4()));
100
101 fs::write(&staged_path, extracted).await.with_context(|| {
102 format!(
103 "failed to stage upgrade binary at {}",
104 staged_path.display()
105 )
106 })?;
107 set_executable_permissions(&staged_path).await?;
108
109 match fs::rename(current_exe, &backup_path).await {
110 Ok(()) => {}
111 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
112 let _ = fs::remove_file(&staged_path).await;
113 if cfg!(windows) {
114 bail!(
115 "cannot replace {} due to insufficient permissions; rerun from an elevated terminal or install to a user-writable path",
116 current_exe.display()
117 );
118 } else {
119 bail!(
120 "cannot replace {} due to insufficient permissions; reinstall with install.sh into a user-writable directory such as ~/.local/bin",
121 current_exe.display()
122 );
123 }
124 }
125 Err(err) => {
126 let _ = fs::remove_file(&staged_path).await;
127 return Err(err).with_context(|| {
128 format!(
129 "failed to prepare existing binary {}",
130 current_exe.display()
131 )
132 });
133 }
134 }
135
136 match fs::rename(&staged_path, current_exe).await {
137 Ok(()) => {
138 let _ = fs::remove_file(&backup_path).await;
139 Ok(())
140 }
141 Err(err) => match fs::rename(&backup_path, current_exe).await {
142 Ok(()) => {
143 let _ = fs::remove_file(&staged_path).await;
144 Err(err).with_context(|| {
145 format!(
146 "failed to install upgraded binary at {}; \
147 original binary has been restored",
148 current_exe.display()
149 )
150 })
151 }
152 Err(rollback_err) => {
153 let _ = fs::remove_file(&staged_path).await;
154 Err(err).with_context(|| {
155 format!(
156 "failed to install upgraded binary at {} \
157 and rollback also failed ({rollback_err:#}); \
158 {} may be missing — reinstall from install.sh",
159 current_exe.display(),
160 current_exe.display()
161 )
162 })
163 }
164 },
165 }
166}
167
168fn extract_binary_from_archive(archive_bytes: &[u8], binary_name: &str) -> Result<Vec<u8>> {
169 let decoder = GzDecoder::new(std::io::Cursor::new(archive_bytes));
170 let mut archive = Archive::new(decoder);
171
172 for entry_result in archive
173 .entries()
174 .context("failed to read archive entries")?
175 {
176 let mut entry = entry_result.context("failed to read release archive entry")?;
177 let path = entry
178 .path()
179 .context("failed to inspect archive entry path")?;
180
181 if path.file_name() == Some(OsStr::new(binary_name)) {
182 let mut extracted = Vec::new();
183 std::io::copy(&mut entry, &mut extracted)
184 .context("failed to extract shine binary from release archive")?;
185 if extracted.is_empty() {
186 bail!("release archive contained an empty shine binary");
187 }
188 return Ok(extracted);
189 }
190 }
191
192 bail!("release archive does not contain a {binary_name} binary")
193}
194
195fn find_release_asset(
196 release: &GithubRelease,
197 channel: ReleaseChannel,
198 os: &str,
199 arch: &str,
200) -> Result<ReleaseAsset> {
201 let target = platform_target(os, arch)?;
202 let expected_name = asset_file_name(release, channel, &target)?;
203
204 let asset = release
205 .assets
206 .iter()
207 .find(|asset| asset.name == expected_name)
208 .ok_or_else(|| {
209 anyhow!(
210 "no release asset named {expected_name} found for {os}/{arch}; expected it to be published with the release"
211 )
212 })?;
213
214 Ok(ReleaseAsset {
215 release_tag: release.tag_name.clone(),
216 target,
217 download_url: asset.browser_download_url.clone(),
218 })
219}
220
221fn platform_target(os: &str, arch: &str) -> Result<String> {
222 platform::release_target(os, arch)
223}
224
225fn asset_file_name(
226 release: &GithubRelease,
227 channel: ReleaseChannel,
228 target: &str,
229) -> Result<String> {
230 match channel {
231 ReleaseChannel::Stable => {
232 let version = parse_release_tag(&release.tag_name)?;
233 Ok(format!("shine-v{version}-{target}.tar.gz"))
234 }
235 ReleaseChannel::Preview => Ok(format!("shine-preview-{target}.tar.gz")),
236 }
237}
238
239async fn installed_version_label(current_exe: &Path, fallback: &str) -> String {
240 match tokio::process::Command::new(current_exe)
241 .arg("--version")
242 .output()
243 .await
244 {
245 Ok(output) if output.status.success() => {
246 let stdout = String::from_utf8_lossy(&output.stdout);
247 parse_binary_version_output(&stdout)
248 .map(str::to_string)
249 .unwrap_or_else(|| fallback.to_string())
250 }
251 _ => fallback.to_string(),
252 }
253}
254
255fn parse_binary_version_output(output: &str) -> Option<&str> {
256 output
257 .trim()
258 .strip_prefix("shine ")?
259 .split_whitespace()
260 .next()
261}
262
263fn preview_release_matches_commit(release: &GithubRelease, current_commit: Option<&str>) -> bool {
264 let Some(release_commit) = parse_preview_release_commit(&release.body) else {
265 return false;
266 };
267 let Some(current_commit) = current_commit.filter(|commit| !commit.is_empty()) else {
268 return false;
269 };
270
271 release_commit.starts_with(current_commit) || current_commit.starts_with(release_commit)
272}
273
274fn parse_preview_release_commit(body: &str) -> Option<&str> {
275 body.lines().find_map(|line| {
276 line.trim()
277 .strip_prefix("- Commit: `")
278 .and_then(|rest| rest.strip_suffix('`'))
279 })
280}
281
282#[cfg(unix)]
283async fn set_executable_permissions(path: &Path) -> Result<()> {
284 use std::os::unix::fs::PermissionsExt;
285
286 let mut permissions = fs::metadata(path)
287 .await
288 .with_context(|| format!("failed to read metadata for {}", path.display()))?
289 .permissions();
290 permissions.set_mode(0o755);
291 fs::set_permissions(path, permissions)
292 .await
293 .with_context(|| format!("failed to mark {} as executable", path.display()))
294}
295
296#[cfg(not(unix))]
297async fn set_executable_permissions(_path: &Path) -> Result<()> {
298 Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303 use super::super::github::GithubReleaseAsset;
304 use super::*;
305 use flate2::{Compression, write::GzEncoder};
306 use tar::{Builder, Header};
307
308 #[test]
309 fn parse_binary_version_output_reads_shine_version() {
310 assert_eq!(
311 parse_binary_version_output("shine 1.0.0-preview (30a34c682 2026-05-25)\n"),
312 Some("1.0.0-preview")
313 );
314 }
315
316 #[test]
317 fn parse_binary_version_output_rejects_unexpected_output() {
318 assert_eq!(parse_binary_version_output("0.21.3"), None);
319 }
320
321 fn archive_with_file(name: &str, content: &[u8]) -> Vec<u8> {
322 let encoder = GzEncoder::new(Vec::new(), Compression::default());
323 let mut builder = Builder::new(encoder);
324 let mut header = Header::new_gnu();
325 header.set_size(content.len() as u64);
326 header.set_mode(0o755);
327 header.set_cksum();
328 builder
329 .append_data(&mut header, name, content)
330 .expect("archive entry should be written");
331 let encoder = builder.into_inner().expect("archive should finish");
332 encoder.finish().expect("gzip should finish")
333 }
334
335 #[test]
336 fn extract_binary_from_archive_uses_platform_binary_name() {
337 let archive = archive_with_file("shine.exe", b"windows-binary");
338 assert_eq!(
339 extract_binary_from_archive(&archive, "shine.exe").unwrap(),
340 b"windows-binary"
341 );
342
343 let err = extract_binary_from_archive(&archive, "shine").unwrap_err();
344 assert!(err.to_string().contains("shine binary"));
345 }
346
347 #[test]
348 fn platform_target_maps_supported_targets() {
349 assert_eq!(
350 platform_target("macos", "aarch64").unwrap(),
351 "darwin-aarch64"
352 );
353 assert_eq!(platform_target("linux", "x86_64").unwrap(), "linux-x86_64");
354 assert_eq!(
355 platform_target("windows", "x86_64").unwrap(),
356 "windows-x86_64"
357 );
358 assert_eq!(
359 platform_target("windows", "aarch64").unwrap(),
360 "windows-aarch64"
361 );
362 }
363
364 #[test]
365 fn asset_file_name_uses_versioned_target_name_for_stable() {
366 let release = GithubRelease {
367 tag_name: "v1.2.3".to_string(),
368 body: String::new(),
369 assets: vec![],
370 };
371 assert_eq!(
372 asset_file_name(&release, ReleaseChannel::Stable, "darwin-aarch64").unwrap(),
373 "shine-v1.2.3-darwin-aarch64.tar.gz"
374 );
375 }
376
377 #[test]
378 fn asset_file_name_uses_fixed_target_name_for_preview() {
379 let release = GithubRelease {
380 tag_name: "preview".to_string(),
381 body: String::new(),
382 assets: vec![],
383 };
384 assert_eq!(
385 asset_file_name(&release, ReleaseChannel::Preview, "linux-x86_64").unwrap(),
386 "shine-preview-linux-x86_64.tar.gz"
387 );
388 assert_eq!(
389 asset_file_name(&release, ReleaseChannel::Preview, "windows-x86_64").unwrap(),
390 "shine-preview-windows-x86_64.tar.gz"
391 );
392 }
393
394 #[test]
395 fn find_release_asset_selects_matching_stable_asset() {
396 let release = GithubRelease {
397 tag_name: "v1.2.3".to_string(),
398 body: String::new(),
399 assets: vec![
400 GithubReleaseAsset {
401 name: "shine-v1.2.3-linux-x86_64.tar.gz".to_string(),
402 browser_download_url: "https://example.test/linux".to_string(),
403 },
404 GithubReleaseAsset {
405 name: "shine-v1.2.3-darwin-aarch64.tar.gz".to_string(),
406 browser_download_url: "https://example.test/macos".to_string(),
407 },
408 ],
409 };
410
411 let asset =
412 find_release_asset(&release, ReleaseChannel::Stable, "macos", "aarch64").unwrap();
413 assert_eq!(asset.release_tag, "v1.2.3");
414 assert_eq!(asset.target, "darwin-aarch64");
415 assert_eq!(asset.download_url, "https://example.test/macos");
416 }
417
418 #[test]
419 fn find_release_asset_selects_matching_preview_asset_without_semver_tag() {
420 let release = GithubRelease {
421 tag_name: "preview".to_string(),
422 body: String::new(),
423 assets: vec![GithubReleaseAsset {
424 name: "shine-preview-linux-x86_64.tar.gz".to_string(),
425 browser_download_url: "https://example.test/preview".to_string(),
426 }],
427 };
428
429 let asset =
430 find_release_asset(&release, ReleaseChannel::Preview, "linux", "x86_64").unwrap();
431 assert_eq!(asset.release_tag, "preview");
432 assert_eq!(asset.target, "linux-x86_64");
433 assert_eq!(asset.download_url, "https://example.test/preview");
434 }
435
436 #[test]
437 fn find_release_asset_selects_matching_windows_asset() {
438 let release = GithubRelease {
439 tag_name: "v1.2.3".to_string(),
440 body: String::new(),
441 assets: vec![GithubReleaseAsset {
442 name: "shine-v1.2.3-windows-x86_64.tar.gz".to_string(),
443 browser_download_url: "https://example.test/windows".to_string(),
444 }],
445 };
446
447 let asset =
448 find_release_asset(&release, ReleaseChannel::Stable, "windows", "x86_64").unwrap();
449 assert_eq!(asset.release_tag, "v1.2.3");
450 assert_eq!(asset.target, "windows-x86_64");
451 assert_eq!(asset.download_url, "https://example.test/windows");
452 }
453
454 #[test]
455 fn find_release_asset_errors_when_target_missing() {
456 let release = GithubRelease {
457 tag_name: "v1.2.3".to_string(),
458 body: String::new(),
459 assets: vec![],
460 };
461
462 let error =
463 find_release_asset(&release, ReleaseChannel::Stable, "linux", "x86_64").unwrap_err();
464 assert!(
465 error
466 .to_string()
467 .contains("no release asset named shine-v1.2.3-linux-x86_64.tar.gz")
468 );
469 }
470
471 #[test]
472 fn parse_preview_release_commit_reads_commit_line_from_release_body() {
473 let body = "Automated preview build from the release branch.\n- Commit: `a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345`\n";
474
475 assert_eq!(
476 parse_preview_release_commit(body),
477 Some("a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345")
478 );
479 }
480
481 #[test]
482 fn preview_release_matches_current_commit_by_prefix() {
483 let release = GithubRelease {
484 tag_name: "preview".to_string(),
485 body:
486 "Automated preview build.\n- Commit: `a618d4af0a8ec0f0d0c5d4f6f9e3e2970cb12345`\n"
487 .to_string(),
488 assets: vec![],
489 };
490
491 assert!(preview_release_matches_commit(&release, Some("a618d4af0")));
492 assert!(!preview_release_matches_commit(&release, Some("b00000000")));
493 assert!(!preview_release_matches_commit(&release, None));
494 }
495}