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