1use clap::Args;
2use flate2::read::GzDecoder;
3use minisign_verify::{PublicKey, Signature};
4use semver::Version;
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use std::collections::BTreeMap;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use tar::Archive;
12use tempfile::TempDir;
13use url::Url;
14
15use crate::cli::ui::{highlight, info, success};
16use crate::error::AppError;
17
18const REPO_URL: &str = env!("CARGO_PKG_REPOSITORY");
19const BINARY_NAME: &str = "cc-switch-tui";
20const CHECKSUMS_FILE_NAME: &str = "checksums.txt";
21const LATEST_MANIFEST_FILE_NAME: &str = "latest.json";
22const HTTP_REQUEST_TIMEOUT_SECS: u64 = 30;
23const MAX_RELEASE_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
24const GITHUB_API_ACCEPT: &str = "application/vnd.github+json";
25const UPDATER_PUBLIC_KEY: &str = include_str!("../../../updater/minisign.pub");
26const USER_AGENT: &str = concat!(
27 env!("CARGO_PKG_NAME"),
28 "-updater/",
29 env!("CARGO_PKG_VERSION")
30);
31
32#[derive(Args, Debug, Clone)]
33pub struct UpdateCommand {
34 #[arg(
36 long = "target-version",
37 value_name = "VERSION",
38 conflicts_with = "target-tag"
39 )]
40 pub target_version: Option<String>,
41
42 #[arg(value_name = "VERSION", id = "target-tag")]
44 pub target_tag: Option<String>,
45}
46
47impl UpdateCommand {
48 fn requested_version(&self) -> Option<&str> {
49 self.target_version
50 .as_deref()
51 .or(self.target_tag.as_deref())
52 }
53}
54
55struct DownloadedAsset {
56 _temp_dir: TempDir,
57 archive_path: PathBuf,
58}
59
60#[derive(Debug, Deserialize, Clone)]
61struct UpdateManifest {
62 version: String,
63 #[serde(default)]
64 #[allow(dead_code)]
65 notes: Option<String>,
66 #[serde(default)]
67 #[allow(dead_code)]
68 pub_date: Option<String>,
69 platforms: BTreeMap<String, UpdatePlatformEntry>,
70}
71
72#[derive(Debug, Deserialize, Clone)]
73struct UpdatePlatformEntry {
74 url: String,
75 signature: String,
76 #[serde(default)]
77 variants: BTreeMap<String, UpdatePlatformVariant>,
78}
79
80#[derive(Debug, Deserialize, Clone)]
81struct UpdatePlatformVariant {
82 url: String,
83 signature: String,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87struct ManifestAsset {
88 url: String,
89 signature: String,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum LinuxLibcPreference {
94 Auto,
95 Musl,
96 Glibc,
97}
98
99#[derive(Debug, Deserialize, Clone)]
100struct ReleaseInfo {
101 tag_name: String,
102 #[serde(default)]
103 assets: Vec<ReleaseAsset>,
104}
105
106#[derive(Debug, Deserialize, Clone)]
107struct ReleaseAsset {
108 name: String,
109 browser_download_url: String,
110 #[serde(default)]
111 digest: Option<String>,
112}
113
114#[derive(Debug, Clone)]
115enum ResolvedRelease {
116 Manifest {
117 target_tag: String,
118 manifest: UpdateManifest,
119 },
120 Legacy {
121 target_tag: String,
122 release: ReleaseInfo,
123 },
124}
125
126#[derive(Debug)]
127enum ManifestFetchError {
128 NotFound,
129 Invalid(AppError),
130}
131
132impl From<AppError> for ManifestFetchError {
133 fn from(value: AppError) -> Self {
134 Self::Invalid(value)
135 }
136}
137
138impl ResolvedRelease {
139 fn target_tag(&self) -> &str {
140 match self {
141 Self::Manifest { target_tag, .. } | Self::Legacy { target_tag, .. } => target_tag,
142 }
143 }
144}
145
146pub fn execute(cmd: UpdateCommand) -> Result<(), AppError> {
147 let runtime = create_runtime()?;
148 runtime.block_on(execute_async(cmd))
149}
150
151async fn execute_async(cmd: UpdateCommand) -> Result<(), AppError> {
152 let current_version = env!("CARGO_PKG_VERSION");
153 let requested_version = cmd.requested_version();
154 let explicit_version = requested_version.is_some_and(|v| !v.trim().is_empty());
155 let client = create_http_client()?;
156 let release = resolve_target_release(&client, REPO_URL, requested_version).await?;
157 let target_tag = release.target_tag().to_string();
158 let target_version = target_tag.trim_start_matches('v');
159
160 if target_version == current_version {
161 println!(
162 "{}",
163 info(&format!("Already on latest version: v{current_version}"))
164 );
165 return Ok(());
166 }
167
168 if should_skip_implicit_downgrade(current_version, target_version, explicit_version) {
169 println!(
170 "{}",
171 info(&format!(
172 "Current version v{current_version} is newer than target {target_tag}; skipping automatic downgrade. Use `cc-switch update {target_tag}` to force."
173 ))
174 );
175 return Ok(());
176 }
177
178 println!(
179 "{}",
180 highlight(&format!("Current version: v{current_version}"))
181 );
182 println!("{}", highlight(&format!("Updating to: {target_tag}")));
183
184 let downloaded_asset = match release {
185 ResolvedRelease::Manifest { manifest, .. } => {
186 let asset = select_current_manifest_asset(&manifest)?;
187 println!("{}", info(&format!("Downloading: {}", asset.url)));
188 println!("{}", info("Verifying updater signature."));
189 let (downloaded_asset, _) =
190 download_manifest_release_asset(&client, &manifest, None).await?;
191 downloaded_asset
192 }
193 ResolvedRelease::Legacy {
194 target_tag,
195 release,
196 } => {
197 let expected_asset_names = current_release_asset_candidates()?;
198 let release_asset = select_release_asset_from_candidates(
199 &release.assets,
200 &target_tag,
201 &expected_asset_names,
202 )
203 .ok_or_else(|| {
204 AppError::Message(format!(
205 "Release {target_tag} does not include any expected assets {:?} (or compatible tagged variants).",
206 expected_asset_names
207 ))
208 })?;
209 let checksum_url = release_checksums_url(REPO_URL, &target_tag)?;
210 println!(
211 "{}",
212 info(&format!(
213 "Downloading: {}",
214 release_asset.browser_download_url.as_str()
215 ))
216 );
217 if release_asset.digest.is_some() {
218 println!(
219 "{}",
220 info("Verifying checksum from release metadata digest.")
221 );
222 } else {
223 println!("{}", info(&format!("Verifying checksum: {checksum_url}")));
224 }
225 let (downloaded_asset, _) =
226 download_legacy_release_asset(&client, &target_tag, Some(&release), None).await?;
227 downloaded_asset
228 }
229 };
230
231 let extracted_binary = extract_binary(&downloaded_asset.archive_path)?;
232 replace_current_binary(&extracted_binary)?;
233
234 println!(
235 "{}",
236 success(&format!("Updated successfully to {target_tag}"))
237 );
238 println!(
239 "{}",
240 info("Run `cc-switch --version` to verify the installed version.")
241 );
242 println!(
243 "{}",
244 info(
245 "If you want to install or refresh managed bash/zsh completions, run: `cc-switch completions install`."
246 )
247 );
248 Ok(())
249}
250
251fn create_runtime() -> Result<tokio::runtime::Runtime, AppError> {
252 tokio::runtime::Builder::new_current_thread()
253 .enable_all()
254 .build()
255 .map_err(|e| AppError::Message(format!("Failed to create runtime: {e}")))
256}
257
258fn create_http_client() -> Result<reqwest::Client, AppError> {
259 reqwest::Client::builder()
260 .timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS))
261 .build()
262 .map_err(|e| AppError::Message(format!("Failed to initialize HTTP client: {e}")))
263}
264
265fn update_manifest_url(repo_url: &str, tag: Option<&str>) -> Result<Url, AppError> {
266 match tag {
267 Some(tag) => release_page_url(
268 repo_url,
269 &format!("download/{tag}/{LATEST_MANIFEST_FILE_NAME}"),
270 ),
271 None => release_page_url(
272 repo_url,
273 &format!("latest/download/{LATEST_MANIFEST_FILE_NAME}"),
274 ),
275 }
276}
277
278async fn fetch_update_manifest(
279 client: &reqwest::Client,
280 repo_url: &str,
281 tag: Option<&str>,
282) -> Result<UpdateManifest, ManifestFetchError> {
283 let url = update_manifest_url(repo_url, tag)?;
284 let response = client
285 .get(url)
286 .header(reqwest::header::USER_AGENT, USER_AGENT)
287 .send()
288 .await
289 .map_err(|e| {
290 ManifestFetchError::Invalid(AppError::Message(format!(
291 "Failed to query update manifest: {e}"
292 )))
293 })?;
294
295 if response.status() == reqwest::StatusCode::NOT_FOUND {
296 return Err(ManifestFetchError::NotFound);
297 }
298
299 response
300 .error_for_status()
301 .map_err(|e| {
302 ManifestFetchError::Invalid(AppError::Message(format!(
303 "Update manifest returned error: {e}"
304 )))
305 })?
306 .json::<UpdateManifest>()
307 .await
308 .map_err(|e| {
309 ManifestFetchError::Invalid(AppError::Message(format!(
310 "Failed to parse update manifest: {e}"
311 )))
312 })
313}
314
315fn manifest_target_tag(manifest: &UpdateManifest) -> Result<String, AppError> {
316 let tag = normalize_tag(manifest.version.trim());
317 validate_target_tag(&tag)?;
318 Ok(tag)
319}
320
321fn validate_requested_manifest_tag(
322 manifest: &UpdateManifest,
323 requested_tag: &str,
324) -> Result<(), AppError> {
325 let manifest_tag = manifest_target_tag(manifest)?;
326 if manifest_tag != requested_tag {
327 return Err(AppError::Message(format!(
328 "Update manifest version {manifest_tag} does not match requested version {requested_tag}."
329 )));
330 }
331 Ok(())
332}
333
334fn current_platform_key() -> Result<&'static str, AppError> {
335 let os = std::env::consts::OS;
336 let arch = std::env::consts::ARCH;
337
338 match (os, arch) {
339 ("macos", "x86_64") => Ok("darwin-x86_64"),
340 ("macos", "aarch64") => Ok("darwin-aarch64"),
341 ("linux", "x86_64") => Ok("linux-x86_64"),
342 ("linux", "aarch64") => Ok("linux-aarch64"),
343 ("windows", "x86_64") => Ok("windows-x86_64"),
344 _ => Err(AppError::Message(format!(
345 "Self-update is not supported for platform {os}/{arch}."
346 ))),
347 }
348}
349
350fn linux_libc_preference() -> Result<LinuxLibcPreference, AppError> {
351 let raw = std::env::var("CC_SWITCH_LINUX_LIBC").unwrap_or_else(|_| "auto".to_string());
352 match raw.trim() {
353 "" | "auto" | "AUTO" => Ok(LinuxLibcPreference::Auto),
354 "musl" | "MUSL" => Ok(LinuxLibcPreference::Musl),
355 "glibc" | "GLIBC" | "gnu" | "GNU" => Ok(LinuxLibcPreference::Glibc),
356 other => Err(AppError::Message(format!(
357 "Unsupported CC_SWITCH_LINUX_LIBC='{other}'. Expected auto, musl, or glibc."
358 ))),
359 }
360}
361
362fn push_manifest_asset(candidates: &mut Vec<ManifestAsset>, asset: ManifestAsset) {
363 if !candidates.contains(&asset) {
364 candidates.push(asset);
365 }
366}
367
368fn asset_looks_like_musl(url: &str) -> bool {
369 url.contains("-musl")
370}
371
372fn select_manifest_asset(
373 manifest: &UpdateManifest,
374 platform_key: &str,
375 preference: LinuxLibcPreference,
376) -> Result<ManifestAsset, AppError> {
377 manifest_asset_candidates(manifest, platform_key, preference)?
378 .into_iter()
379 .next()
380 .ok_or_else(|| {
381 AppError::Message("Update manifest does not contain a usable asset.".to_string())
382 })
383}
384
385fn manifest_asset_candidates(
386 manifest: &UpdateManifest,
387 platform_key: &str,
388 preference: LinuxLibcPreference,
389) -> Result<Vec<ManifestAsset>, AppError> {
390 let entry = manifest.platforms.get(platform_key).ok_or_else(|| {
391 AppError::Message(format!(
392 "Update manifest does not provide platform entry '{platform_key}'."
393 ))
394 })?;
395
396 let primary = ManifestAsset {
397 url: entry.url.clone(),
398 signature: entry.signature.clone(),
399 };
400
401 if !platform_key.starts_with("linux-") {
402 return Ok(vec![primary]);
403 }
404
405 let musl_variant = entry.variants.get("musl").map(|variant| ManifestAsset {
406 url: variant.url.clone(),
407 signature: variant.signature.clone(),
408 });
409 let glibc_variant = entry.variants.get("glibc").map(|variant| ManifestAsset {
410 url: variant.url.clone(),
411 signature: variant.signature.clone(),
412 });
413
414 let mut candidates = Vec::new();
415 match preference {
416 LinuxLibcPreference::Auto => {
417 push_manifest_asset(&mut candidates, primary);
418 if let Some(asset) = glibc_variant {
419 push_manifest_asset(&mut candidates, asset);
420 }
421 if let Some(asset) = musl_variant {
422 push_manifest_asset(&mut candidates, asset);
423 }
424 }
425 LinuxLibcPreference::Musl => {
426 if let Some(asset) = musl_variant {
427 push_manifest_asset(&mut candidates, asset);
428 } else if asset_looks_like_musl(&primary.url) {
429 push_manifest_asset(&mut candidates, primary.clone());
430 } else {
431 return Err(AppError::Message(format!(
432 "Update manifest does not provide a musl variant for platform '{platform_key}'."
433 )));
434 }
435 }
436 LinuxLibcPreference::Glibc => {
437 if let Some(asset) = glibc_variant {
438 push_manifest_asset(&mut candidates, asset);
439 } else if !asset_looks_like_musl(&primary.url) {
440 push_manifest_asset(&mut candidates, primary.clone());
441 } else {
442 return Err(AppError::Message(format!(
443 "Update manifest does not provide a glibc variant for platform '{platform_key}'."
444 )));
445 }
446 push_manifest_asset(&mut candidates, primary);
447 if let Some(asset) = musl_variant {
448 push_manifest_asset(&mut candidates, asset);
449 }
450 }
451 }
452
453 Ok(candidates)
454}
455
456fn select_current_manifest_asset(manifest: &UpdateManifest) -> Result<ManifestAsset, AppError> {
457 select_manifest_asset(manifest, current_platform_key()?, linux_libc_preference()?)
458}
459
460fn release_asset_candidates_for_platform(
461 os: &str,
462 arch: &str,
463 preference: LinuxLibcPreference,
464) -> Result<Vec<String>, AppError> {
465 let names = match (os, arch) {
466 ("macos", "x86_64") => vec![
467 "cc-switch-tui-darwin-universal.tar.gz".to_string(),
468 "cc-switch-tui-darwin-x64.tar.gz".to_string(),
469 ],
470 ("macos", "aarch64") => vec![
471 "cc-switch-tui-darwin-universal.tar.gz".to_string(),
472 "cc-switch-tui-darwin-arm64.tar.gz".to_string(),
473 ],
474 ("linux", "x86_64") => match preference {
475 LinuxLibcPreference::Auto => vec![
476 "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
477 "cc-switch-tui-linux-x64.tar.gz".to_string(),
478 ],
479 LinuxLibcPreference::Musl => vec!["cc-switch-tui-linux-x64-musl.tar.gz".to_string()],
480 LinuxLibcPreference::Glibc => vec![
481 "cc-switch-tui-linux-x64.tar.gz".to_string(),
482 "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
483 ],
484 },
485 ("linux", "aarch64") => match preference {
486 LinuxLibcPreference::Auto => vec![
487 "cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
488 "cc-switch-tui-linux-arm64.tar.gz".to_string(),
489 ],
490 LinuxLibcPreference::Musl => {
491 vec!["cc-switch-tui-linux-arm64-musl.tar.gz".to_string()]
492 }
493 LinuxLibcPreference::Glibc => vec![
494 "cc-switch-tui-linux-arm64.tar.gz".to_string(),
495 "cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
496 ],
497 },
498 ("windows", "x86_64") => vec!["cc-switch-tui-windows-x64.zip".to_string()],
499 _ => {
500 return Err(AppError::Message(format!(
501 "Self-update is not supported for platform {os}/{arch}."
502 )))
503 }
504 };
505
506 Ok(names)
507}
508
509fn current_release_asset_candidates() -> Result<Vec<String>, AppError> {
510 release_asset_candidates_for_platform(
511 std::env::consts::OS,
512 std::env::consts::ARCH,
513 linux_libc_preference()?,
514 )
515}
516
517fn select_release_asset_from_candidates<'a>(
518 assets: &'a [ReleaseAsset],
519 target_tag: &str,
520 expected_asset_names: &[String],
521) -> Option<&'a ReleaseAsset> {
522 expected_asset_names.iter().find_map(|expected_asset_name| {
523 select_release_asset(assets, target_tag, expected_asset_name)
524 })
525}
526
527fn parse_public_key(public_key_text: &str) -> Result<PublicKey, AppError> {
528 PublicKey::decode(public_key_text.trim())
529 .or_else(|_| PublicKey::from_base64(public_key_text.trim()))
530 .map_err(|e| AppError::Message(format!("Invalid updater public key: {e}")))
531}
532
533fn verify_minisign_signature(
534 payload: &[u8],
535 signature_text: &str,
536 public_key_text: &str,
537) -> Result<(), AppError> {
538 let public_key = parse_public_key(public_key_text)?;
539 let signature = Signature::decode(signature_text.trim())
540 .map_err(|e| AppError::Message(format!("Invalid updater signature: {e}")))?;
541 public_key
542 .verify(payload, &signature, false)
543 .map_err(|e| AppError::Message(format!("Updater signature verification failed: {e}")))
544}
545
546fn verify_downloaded_asset_signature(
547 archive_path: &Path,
548 signature_text: &str,
549) -> Result<(), AppError> {
550 let payload = fs::read(archive_path).map_err(|e| AppError::io(archive_path, e))?;
551 verify_minisign_signature(&payload, signature_text, UPDATER_PUBLIC_KEY)
552}
553
554fn asset_name_from_url(url: &str) -> Result<String, AppError> {
555 let parsed = Url::parse(url)
556 .map_err(|e| AppError::Message(format!("Invalid asset URL '{url}': {e}")))?;
557 let asset_name = parsed
558 .path_segments()
559 .and_then(|segments| segments.last())
560 .filter(|value| !value.is_empty())
561 .ok_or_else(|| AppError::Message(format!("Asset URL has no file name: {url}")))?;
562
563 sanitized_asset_file_name(asset_name).map(str::to_string)
564}
565
566async fn download_manifest_release_asset(
567 client: &reqwest::Client,
568 manifest: &UpdateManifest,
569 on_progress: Option<&dyn Fn(u64, Option<u64>)>,
570) -> Result<(DownloadedAsset, ManifestAsset), AppError> {
571 let assets =
572 manifest_asset_candidates(manifest, current_platform_key()?, linux_libc_preference()?)?;
573 let mut last_error = None;
574
575 for asset in assets {
576 let asset_name = asset_name_from_url(&asset.url)?;
577 match download_release_asset(client, &asset.url, &asset_name, on_progress).await {
578 Ok(downloaded_asset) => {
579 verify_downloaded_asset_signature(
580 &downloaded_asset.archive_path,
581 &asset.signature,
582 )?;
583 return Ok((downloaded_asset, asset));
584 }
585 Err(err) => last_error = Some(err),
586 }
587 }
588
589 Err(last_error.unwrap_or_else(|| {
590 AppError::Message("Update manifest did not produce a downloadable asset.".to_string())
591 }))
592}
593
594async fn download_legacy_release_asset(
595 client: &reqwest::Client,
596 target_tag: &str,
597 release: Option<&ReleaseInfo>,
598 on_progress: Option<&dyn Fn(u64, Option<u64>)>,
599) -> Result<(DownloadedAsset, ReleaseAsset), AppError> {
600 let release = match release {
601 Some(release) => release.clone(),
602 None => fetch_release_by_tag(client, REPO_URL, target_tag).await?,
603 };
604 let expected_asset_names = current_release_asset_candidates()?;
605 let release_asset = select_release_asset_from_candidates(
606 &release.assets,
607 target_tag,
608 &expected_asset_names,
609 )
610 .ok_or_else(|| {
611 AppError::Message(format!(
612 "Release {target_tag} does not include any expected assets {:?} (or compatible tagged variants).",
613 expected_asset_names
614 ))
615 })?
616 .clone();
617 let downloaded_asset = download_release_asset(
618 client,
619 release_asset.browser_download_url.as_str(),
620 release_asset.name.as_str(),
621 on_progress,
622 )
623 .await?;
624 verify_asset_checksum(
625 client,
626 &downloaded_asset.archive_path,
627 target_tag,
628 &release_asset,
629 )
630 .await?;
631 Ok((downloaded_asset, release_asset))
632}
633
634async fn resolve_target_release(
635 client: &reqwest::Client,
636 repo_url: &str,
637 version: Option<&str>,
638) -> Result<ResolvedRelease, AppError> {
639 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
640 let target_tag = normalize_tag(version);
641 validate_target_tag(&target_tag)?;
642
643 match fetch_update_manifest(client, repo_url, Some(&target_tag)).await {
644 Ok(manifest) => {
645 validate_requested_manifest_tag(&manifest, &target_tag)?;
646 return Ok(ResolvedRelease::Manifest {
647 target_tag,
648 manifest,
649 });
650 }
651 Err(ManifestFetchError::NotFound) => {}
652 Err(ManifestFetchError::Invalid(err)) => return Err(err),
653 }
654
655 return Ok(ResolvedRelease::Legacy {
656 target_tag: target_tag.clone(),
657 release: fetch_release_by_tag(client, repo_url, &target_tag).await?,
658 });
659 }
660
661 match fetch_update_manifest(client, repo_url, None).await {
662 Ok(manifest) => {
663 return Ok(ResolvedRelease::Manifest {
664 target_tag: manifest_target_tag(&manifest)?,
665 manifest,
666 });
667 }
668 Err(ManifestFetchError::NotFound) => {}
669 Err(ManifestFetchError::Invalid(err)) => return Err(err),
670 }
671
672 let target_tag = fetch_latest_release_tag(client, repo_url).await?;
673 Ok(ResolvedRelease::Legacy {
674 target_tag: target_tag.clone(),
675 release: fetch_release_by_tag(client, repo_url, &target_tag).await?,
676 })
677}
678
679fn validate_target_tag(tag: &str) -> Result<(), AppError> {
680 if !tag.starts_with('v') {
681 return Err(AppError::Message(format!(
682 "Invalid version tag '{tag}': must start with 'v'."
683 )));
684 }
685 if tag.len() > 64 {
686 return Err(AppError::Message(format!(
687 "Invalid version tag '{tag}': too long."
688 )));
689 }
690 if tag.contains('/') || tag.contains('\\') || tag.contains("..") {
691 return Err(AppError::Message(format!(
692 "Invalid version tag '{tag}': contains forbidden path characters."
693 )));
694 }
695 if !tag
696 .chars()
697 .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_')
698 {
699 return Err(AppError::Message(format!(
700 "Invalid version tag '{tag}': only [A-Za-z0-9._-] allowed."
701 )));
702 }
703 Ok(())
704}
705
706fn normalize_tag(version: &str) -> String {
707 if version.starts_with('v') {
708 version.to_string()
709 } else {
710 format!("v{version}")
711 }
712}
713
714async fn fetch_latest_release_tag(
715 client: &reqwest::Client,
716 repo_url: &str,
717) -> Result<String, AppError> {
718 let api_url = release_api_url(repo_url, "latest")?;
719 let response = client
720 .get(api_url)
721 .header(reqwest::header::USER_AGENT, USER_AGENT)
722 .header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT)
723 .send()
724 .await
725 .map_err(|e| AppError::Message(format!("Failed to query latest release: {e}")))?;
726
727 if matches!(
728 response.status(),
729 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS
730 ) {
731 return fetch_latest_release_tag_from_release_page(client, repo_url).await;
732 }
733
734 let release = response
735 .error_for_status()
736 .map_err(|e| AppError::Message(format!("Release API returned error: {e}")))?
737 .json::<ReleaseInfo>()
738 .await
739 .map_err(|e| AppError::Message(format!("Failed to parse latest release response: {e}")))?;
740
741 Ok(release.tag_name)
742}
743
744async fn fetch_release_by_tag(
745 client: &reqwest::Client,
746 repo_url: &str,
747 tag: &str,
748) -> Result<ReleaseInfo, AppError> {
749 let api_url = release_api_url(repo_url, &format!("tags/{tag}"))?;
750 client
751 .get(api_url)
752 .header(reqwest::header::USER_AGENT, USER_AGENT)
753 .header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT)
754 .send()
755 .await
756 .map_err(|e| AppError::Message(format!("Failed to query release {tag}: {e}")))?
757 .error_for_status()
758 .map_err(|e| AppError::Message(format!("Release API returned error for {tag}: {e}")))?
759 .json::<ReleaseInfo>()
760 .await
761 .map_err(|e| AppError::Message(format!("Failed to parse release response for {tag}: {e}")))
762}
763
764async fn fetch_latest_release_tag_from_release_page(
765 client: &reqwest::Client,
766 repo_url: &str,
767) -> Result<String, AppError> {
768 let latest_url = release_page_url(repo_url, "latest")?;
769 let response = client
770 .get(latest_url)
771 .header(reqwest::header::USER_AGENT, USER_AGENT)
772 .send()
773 .await
774 .map_err(|e| AppError::Message(format!("Failed to query latest release page: {e}")))?
775 .error_for_status()
776 .map_err(|e| AppError::Message(format!("Latest release page returned error: {e}")))?;
777
778 extract_release_tag_from_url(response.url()).ok_or_else(|| {
779 AppError::Message(format!(
780 "Failed to resolve latest release tag from {}.",
781 response.url()
782 ))
783 })
784}
785
786fn release_page_url(repo_url: &str, suffix: &str) -> Result<Url, AppError> {
787 let repo_url = Url::parse(repo_url)
788 .map_err(|e| AppError::Message(format!("Invalid repository URL '{repo_url}': {e}")))?;
789
790 let path = repo_url.path().trim_matches('/');
791 let mut parts = path.split('/');
792 let owner = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
793 AppError::Message(format!(
794 "Repository URL must include owner and repo: {repo_url}"
795 ))
796 })?;
797 let repo = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
798 AppError::Message(format!(
799 "Repository URL must include owner and repo: {repo_url}"
800 ))
801 })?;
802 if parts.next().is_some() {
803 return Err(AppError::Message(format!(
804 "Repository URL must be in '<host>/<owner>/<repo>' format: {repo_url}"
805 )));
806 }
807 let repo = repo.strip_suffix(".git").unwrap_or(repo);
808
809 let mut release_url = repo_url.clone();
810 release_url.set_path(&format!("/{owner}/{repo}/releases/{suffix}"));
811 release_url.set_query(None);
812 release_url.set_fragment(None);
813
814 Ok(release_url)
815}
816
817fn release_api_url(repo_url: &str, suffix: &str) -> Result<Url, AppError> {
818 let repo_url = Url::parse(repo_url)
819 .map_err(|e| AppError::Message(format!("Invalid repository URL '{repo_url}': {e}")))?;
820 let host = repo_url
821 .host_str()
822 .ok_or_else(|| AppError::Message(format!("Repository URL is missing host: {repo_url}")))?;
823
824 let path = repo_url.path().trim_matches('/');
825 let mut parts = path.split('/');
826 let owner = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
827 AppError::Message(format!(
828 "Repository URL must include owner and repo: {repo_url}"
829 ))
830 })?;
831 let repo = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
832 AppError::Message(format!(
833 "Repository URL must include owner and repo: {repo_url}"
834 ))
835 })?;
836 if parts.next().is_some() {
837 return Err(AppError::Message(format!(
838 "Repository URL must be in '<host>/<owner>/<repo>' format: {repo_url}"
839 )));
840 }
841 let repo = repo.strip_suffix(".git").unwrap_or(repo);
842
843 let api_path = if host == "github.com" {
844 format!("/repos/{owner}/{repo}/releases/{suffix}")
845 } else {
846 format!("/api/v3/repos/{owner}/{repo}/releases/{suffix}")
847 };
848
849 let mut api_url = repo_url.clone();
850 if host == "github.com" {
851 api_url
852 .set_host(Some("api.github.com"))
853 .map_err(|_| AppError::Message("Failed to set GitHub API host.".to_string()))?;
854 }
855 api_url.set_path(&api_path);
856 api_url.set_query(None);
857 api_url.set_fragment(None);
858
859 Ok(api_url)
860}
861
862fn release_checksums_url(repo_url: &str, tag: &str) -> Result<Url, AppError> {
863 release_page_url(repo_url, &format!("download/{tag}/{CHECKSUMS_FILE_NAME}"))
864}
865
866fn extract_release_tag_from_url(url: &Url) -> Option<String> {
867 let segments = url.path_segments()?.collect::<Vec<_>>();
868 segments
869 .windows(3)
870 .find(|window| window[0] == "releases" && window[1] == "tag")
871 .map(|window| window[2].to_string())
872}
873
874fn tagged_asset_name(tag: &str, asset_name: &str) -> String {
875 if let Some(suffix) = asset_name.strip_prefix("cc-switch-tui-") {
876 return format!("cc-switch-tui-{tag}-{suffix}");
877 }
878 asset_name.to_string()
879}
880
881fn release_asset_names(tag: &str, asset_name: &str) -> Vec<String> {
882 let tagged = tagged_asset_name(tag, asset_name);
883 if tagged == asset_name {
884 vec![asset_name.to_string()]
885 } else {
886 vec![asset_name.to_string(), tagged]
887 }
888}
889
890fn select_release_asset<'a>(
891 assets: &'a [ReleaseAsset],
892 target_tag: &str,
893 expected_asset_name: &str,
894) -> Option<&'a ReleaseAsset> {
895 let expected_names = release_asset_names(target_tag, expected_asset_name);
896 expected_names
897 .iter()
898 .find_map(|expected_name| assets.iter().find(|asset| asset.name == *expected_name))
899}
900
901async fn download_release_asset(
902 client: &reqwest::Client,
903 url: &str,
904 asset_name: &str,
905 on_progress: Option<&dyn Fn(u64, Option<u64>)>,
906) -> Result<DownloadedAsset, AppError> {
907 let response = client
908 .get(url)
909 .header(reqwest::header::USER_AGENT, USER_AGENT)
910 .send()
911 .await
912 .map_err(|e| AppError::Message(format!("Failed to download release asset: {e}")))?;
913
914 let mut response = response
915 .error_for_status()
916 .map_err(|e| AppError::Message(format!("Release asset request failed: {e}")))?;
917 let content_length = response.content_length();
918 if let Some(cl) = content_length {
919 validate_download_size_limit(cl, asset_name)?;
920 }
921
922 let temp_dir = tempfile::tempdir()
923 .map_err(|e| AppError::Message(format!("Failed to create temp directory: {e}")))?;
924 let file_name = sanitized_asset_file_name(asset_name)?;
925 let archive_path = temp_dir.path().join(file_name);
926 let mut output = fs::File::create(&archive_path).map_err(|e| AppError::io(&archive_path, e))?;
927 let mut downloaded_bytes = 0_u64;
928 let mut last_reported = 0_u64;
929
930 while let Some(chunk) = response
931 .chunk()
932 .await
933 .map_err(|e| AppError::Message(format!("Failed to read release asset chunk: {e}")))?
934 {
935 downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64);
936 validate_download_size_limit(downloaded_bytes, asset_name)?;
937 output
938 .write_all(&chunk)
939 .map_err(|e| AppError::io(&archive_path, e))?;
940
941 if let Some(cb) = on_progress {
942 if downloaded_bytes - last_reported >= 64 * 1024 {
943 cb(downloaded_bytes, content_length);
944 last_reported = downloaded_bytes;
945 }
946 }
947 }
948
949 if let Some(cb) = on_progress {
950 cb(downloaded_bytes, content_length);
951 }
952
953 Ok(DownloadedAsset {
954 _temp_dir: temp_dir,
955 archive_path,
956 })
957}
958
959fn sanitized_asset_file_name(asset_name: &str) -> Result<&str, AppError> {
960 Path::new(asset_name)
961 .file_name()
962 .and_then(|name| name.to_str())
963 .ok_or_else(|| AppError::Message(format!("Invalid asset name: {asset_name}")))
964}
965
966fn validate_download_size_limit(size_bytes: u64, asset_name: &str) -> Result<(), AppError> {
967 if size_bytes <= MAX_RELEASE_ASSET_SIZE_BYTES {
968 return Ok(());
969 }
970 let max_mb = MAX_RELEASE_ASSET_SIZE_BYTES / (1024 * 1024);
971 let size_mb = size_bytes / (1024 * 1024);
972 Err(AppError::Message(format!(
973 "Release asset '{asset_name}' is too large ({size_mb} MB). Maximum allowed size is {max_mb} MB."
974 )))
975}
976
977async fn verify_asset_checksum(
978 client: &reqwest::Client,
979 archive_path: &Path,
980 target_tag: &str,
981 release_asset: &ReleaseAsset,
982) -> Result<(), AppError> {
983 let actual = compute_sha256_hex(archive_path)?;
984 let expected = if let Some(expected) = release_asset
985 .digest
986 .as_deref()
987 .and_then(parse_sha256_digest)
988 {
989 expected
990 } else {
991 let checksum_url = release_checksums_url(REPO_URL, target_tag)?;
992 let checksum_content = download_text(client, checksum_url.as_str()).await?;
993 parse_checksum_for_asset(&checksum_content, release_asset.name.as_str())?
994 };
995
996 if actual != expected {
997 return Err(AppError::Message(format!(
998 "Checksum mismatch for {}: expected {expected}, got {actual}.",
999 release_asset.name
1000 )));
1001 }
1002
1003 Ok(())
1004}
1005
1006fn compute_sha256_hex(path: &Path) -> Result<String, AppError> {
1007 let mut file = fs::File::open(path).map_err(|e| AppError::io(path, e))?;
1008 let mut hasher = Sha256::new();
1009 std::io::copy(&mut file, &mut hasher).map_err(|e| AppError::io(path, e))?;
1010
1011 Ok(format!("{:x}", hasher.finalize()))
1012}
1013
1014fn should_skip_implicit_downgrade(
1015 current_version: &str,
1016 target_version: &str,
1017 explicit_version: bool,
1018) -> bool {
1019 if explicit_version {
1020 return false;
1021 }
1022 let Ok(current) = Version::parse(current_version) else {
1023 return false;
1024 };
1025 let Ok(target) = Version::parse(target_version) else {
1026 return false;
1027 };
1028 target < current
1029}
1030
1031async fn download_text(client: &reqwest::Client, url: &str) -> Result<String, AppError> {
1032 let response = client
1033 .get(url)
1034 .header(reqwest::header::USER_AGENT, USER_AGENT)
1035 .send()
1036 .await
1037 .map_err(|e| AppError::Message(format!("Failed to download checksum file: {e}")))?;
1038
1039 response
1040 .error_for_status()
1041 .map_err(|e| AppError::Message(format!("Checksum file request failed: {e}")))?
1042 .text()
1043 .await
1044 .map_err(|e| AppError::Message(format!("Failed to read checksum file body: {e}")))
1045}
1046
1047fn parse_checksum_for_asset(checksum_content: &str, asset_name: &str) -> Result<String, AppError> {
1048 let expected = checksum_content
1049 .lines()
1050 .filter_map(|line| {
1051 let line = line.trim_end();
1052 if line.is_empty() {
1053 return None;
1054 }
1055
1056 let (hash, file) = parse_sha256sum_line(line)?;
1057
1058 if file == asset_name {
1059 Some(hash.to_ascii_lowercase())
1060 } else {
1061 None
1062 }
1063 })
1064 .next();
1065
1066 expected.ok_or_else(|| {
1067 AppError::Message(format!(
1068 "Unable to find SHA256 for {asset_name} in {CHECKSUMS_FILE_NAME}."
1069 ))
1070 })
1071}
1072
1073fn parse_sha256sum_line(line: &str) -> Option<(&str, &str)> {
1074 if line.len() < 66 {
1078 return None;
1079 }
1080
1081 let (hash, remainder) = line.split_at(64);
1082 if !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
1083 return None;
1084 }
1085
1086 if let Some(file) = remainder
1087 .strip_prefix(" ")
1088 .or_else(|| remainder.strip_prefix(" *"))
1089 {
1090 return Some((hash, file));
1091 }
1092
1093 None
1094}
1095
1096fn parse_sha256_digest(digest: &str) -> Option<String> {
1097 let digest = digest.strip_prefix("sha256:")?;
1098 if digest.len() != 64 || !digest.chars().all(|ch| ch.is_ascii_hexdigit()) {
1099 return None;
1100 }
1101 Some(digest.to_ascii_lowercase())
1102}
1103
1104fn extract_binary(archive_path: &Path) -> Result<PathBuf, AppError> {
1105 let extract_dir = archive_path
1106 .parent()
1107 .ok_or_else(|| AppError::Message("Invalid archive path".to_string()))?
1108 .join("extracted");
1109 fs::create_dir_all(&extract_dir).map_err(|e| AppError::io(&extract_dir, e))?;
1110
1111 if cfg!(windows) {
1112 extract_zip_binary(archive_path, &extract_dir)
1113 } else {
1114 extract_tar_binary(archive_path, &extract_dir)
1115 }
1116}
1117
1118#[cfg(not(windows))]
1119fn extract_tar_binary(archive_path: &Path, extract_dir: &Path) -> Result<PathBuf, AppError> {
1120 let file = fs::File::open(archive_path).map_err(|e| AppError::io(archive_path, e))?;
1121 let decoder = GzDecoder::new(file);
1122 let mut archive = Archive::new(decoder);
1123 let entries = archive
1124 .entries()
1125 .map_err(|e| AppError::Message(format!("Failed to read archive entries: {e}")))?;
1126
1127 for entry in entries {
1128 let mut entry =
1129 entry.map_err(|e| AppError::Message(format!("Failed to read archive entry: {e}")))?;
1130
1131 if !entry.header().entry_type().is_file() {
1132 continue;
1133 }
1134
1135 let entry_path = entry
1136 .path()
1137 .map_err(|e| AppError::Message(format!("Failed to inspect archive entry path: {e}")))?;
1138 if entry_path.file_name().and_then(|name| name.to_str()) != Some(BINARY_NAME) {
1139 continue;
1140 }
1141
1142 let binary_path = extract_dir.join(BINARY_NAME);
1143 let mut output =
1144 fs::File::create(&binary_path).map_err(|e| AppError::io(&binary_path, e))?;
1145 std::io::copy(&mut entry, &mut output)
1146 .map_err(|e| AppError::Message(format!("Failed to unpack binary from TAR: {e}")))?;
1147
1148 #[cfg(unix)]
1149 {
1150 use std::os::unix::fs::PermissionsExt;
1151 let perms = fs::Permissions::from_mode(0o755);
1152 fs::set_permissions(&binary_path, perms).map_err(|e| AppError::io(&binary_path, e))?;
1153 }
1154
1155 return Ok(binary_path);
1156 }
1157
1158 Err(AppError::Message(format!(
1159 "Extracted archive does not contain expected binary: {BINARY_NAME}"
1160 )))
1161}
1162
1163#[cfg(not(windows))]
1164fn extract_zip_binary(_archive_path: &Path, _extract_dir: &Path) -> Result<PathBuf, AppError> {
1165 Err(AppError::Message(
1166 "ZIP extraction is only supported on Windows.".to_string(),
1167 ))
1168}
1169
1170#[cfg(windows)]
1171fn extract_zip_binary(archive_path: &Path, extract_dir: &Path) -> Result<PathBuf, AppError> {
1172 let file = fs::File::open(archive_path).map_err(|e| AppError::io(archive_path, e))?;
1173 let mut zip = zip::ZipArchive::new(file)
1174 .map_err(|e| AppError::Message(format!("Failed to open ZIP archive: {e}")))?;
1175 let binary_filename = format!("{BINARY_NAME}.exe");
1176
1177 let mut entry = zip.by_name(&binary_filename).map_err(|_| {
1178 AppError::Message(format!("ZIP archive does not contain {binary_filename}"))
1179 })?;
1180
1181 let binary_path = extract_dir.join(binary_filename);
1182 let mut output = fs::File::create(&binary_path).map_err(|e| AppError::io(&binary_path, e))?;
1183 std::io::copy(&mut entry, &mut output)
1184 .map_err(|e| AppError::Message(format!("Failed to extract binary from ZIP: {e}")))?;
1185
1186 Ok(binary_path)
1187}
1188
1189#[cfg(windows)]
1190fn extract_tar_binary(_archive_path: &Path, _extract_dir: &Path) -> Result<PathBuf, AppError> {
1191 Err(AppError::Message(
1192 "TAR extraction is not supported on Windows.".to_string(),
1193 ))
1194}
1195
1196fn replace_current_binary(new_binary_path: &Path) -> Result<(), AppError> {
1197 #[cfg(windows)]
1198 {
1199 return self_replace::self_replace(new_binary_path).map_err(|e| {
1200 AppError::Message(format!(
1201 "Failed to replace running executable on Windows: {e}"
1202 ))
1203 });
1204 }
1205
1206 #[cfg(not(windows))]
1207 {
1208 let current_binary = std::env::current_exe().map_err(|e| {
1209 AppError::Message(format!("Failed to resolve current executable path: {e}"))
1210 })?;
1211 let parent = current_binary.parent().ok_or_else(|| {
1212 AppError::Message("Current executable path has no parent directory.".to_string())
1213 })?;
1214
1215 let staged_binary = parent.join(format!("{BINARY_NAME}.new"));
1216 remove_file_if_present(&staged_binary)?;
1217
1218 fs::copy(new_binary_path, &staged_binary)
1219 .map_err(|e| map_update_permission_error(&staged_binary, e))?;
1220
1221 #[cfg(unix)]
1222 {
1223 use std::os::unix::fs::PermissionsExt;
1224 let perms = fs::Permissions::from_mode(0o755);
1225 fs::set_permissions(&staged_binary, perms)
1226 .map_err(|e| map_update_permission_error(&staged_binary, e))?;
1227 }
1228
1229 fs::rename(&staged_binary, ¤t_binary)
1230 .map_err(|e| map_update_permission_error(¤t_binary, e))?;
1231 Ok(())
1232 }
1233}
1234
1235fn remove_file_if_present(path: &Path) -> Result<(), AppError> {
1236 match fs::remove_file(path) {
1237 Ok(()) => Ok(()),
1238 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1239 Err(err) => Err(AppError::io(path, err)),
1240 }
1241}
1242
1243fn map_update_permission_error(target: &Path, err: std::io::Error) -> AppError {
1244 if err.kind() == std::io::ErrorKind::PermissionDenied {
1245 return AppError::Message(format!(
1246 "Permission denied while updating {}. Re-run with elevated privileges (for example: sudo cc-switch update), or use your package manager update command.",
1247 target.display()
1248 ));
1249 }
1250 AppError::io(target, err)
1251}
1252
1253pub(crate) struct UpdateCheckInfo {
1254 pub current_version: String,
1255 pub target_tag: String,
1256 pub is_already_latest: bool,
1257 pub is_downgrade: bool,
1258}
1259
1260pub(crate) async fn check_for_update() -> Result<UpdateCheckInfo, AppError> {
1261 let current_version = env!("CARGO_PKG_VERSION");
1262 let client = create_http_client()?;
1263 let target_tag = resolve_target_release(&client, REPO_URL, None)
1264 .await?
1265 .target_tag()
1266 .to_string();
1267 let target_version = target_tag.trim_start_matches('v');
1268
1269 let is_already_latest = target_version == current_version;
1270 let is_downgrade = should_skip_implicit_downgrade(current_version, target_version, false);
1271
1272 Ok(UpdateCheckInfo {
1273 current_version: current_version.to_string(),
1274 target_tag,
1275 is_already_latest,
1276 is_downgrade,
1277 })
1278}
1279
1280pub(crate) async fn download_and_apply(
1281 target_tag: &str,
1282 on_progress: impl Fn(u64, Option<u64>),
1283) -> Result<(), AppError> {
1284 let client = create_http_client()?;
1285 let release = resolve_target_release(&client, REPO_URL, Some(target_tag)).await?;
1286 let downloaded_asset = match release {
1287 ResolvedRelease::Manifest { manifest, .. } => {
1288 let (downloaded_asset, _) =
1289 download_manifest_release_asset(&client, &manifest, Some(&on_progress)).await?;
1290 downloaded_asset
1291 }
1292 ResolvedRelease::Legacy {
1293 target_tag,
1294 release,
1295 } => {
1296 let (downloaded_asset, _) = download_legacy_release_asset(
1297 &client,
1298 &target_tag,
1299 Some(&release),
1300 Some(&on_progress),
1301 )
1302 .await?;
1303 downloaded_asset
1304 }
1305 };
1306 let extracted_binary = extract_binary(&downloaded_asset.archive_path)?;
1307 replace_current_binary(&extracted_binary)?;
1308
1309 Ok(())
1310}
1311
1312#[cfg(test)]
1313mod tests;