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(
36        long = "target-version",
37        value_name = "VERSION",
38        conflicts_with = "target-tag"
39    )]
40    pub target_version: Option<String>,
41
42    /// Target version (example: v4.6.2). Defaults to latest release.
43    #[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    // sha256sum output format:
1085    // - text mode:   "<64-hex>  <filename>"
1086    // - binary mode: "<64-hex> *<filename>"
1087    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, &current_binary)
1240            .map_err(|e| map_update_permission_error(&current_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;