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