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