Skip to main content

cc_switch_lib/cli/commands/
update.rs

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    /// Target version (example: v4.6.2). Defaults to latest release.
35    #[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    // sha256sum output format:
1068    // - text mode:   "<64-hex>  <filename>"
1069    // - binary mode: "<64-hex> *<filename>"
1070    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, &current_binary)
1223            .map_err(|e| map_update_permission_error(&current_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;