use clap::Args;
use flate2::read::GzDecoder;
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tar::Archive;
use tempfile::TempDir;
use url::Url;
use crate::cli::ui::{highlight, info, success};
use crate::error::AppError;
const REPO_URL: &str = env!("CARGO_PKG_REPOSITORY");
const BINARY_NAME: &str = "cc-switch-tui";
const CHECKSUMS_FILE_NAME: &str = "checksums.txt";
const LATEST_MANIFEST_FILE_NAME: &str = "latest.json";
const HTTP_REQUEST_TIMEOUT_SECS: u64 = 30;
const MAX_RELEASE_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const GITHUB_API_ACCEPT: &str = "application/vnd.github+json";
const UPDATER_PUBLIC_KEY: &str = include_str!("../../../updater/minisign.pub");
const USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"-updater/",
env!("CARGO_PKG_VERSION")
);
#[derive(Args, Debug, Clone)]
pub struct UpdateCommand {
#[arg(
long = "target-version",
value_name = "VERSION",
conflicts_with = "target-tag"
)]
pub target_version: Option<String>,
#[arg(value_name = "VERSION", id = "target-tag")]
pub target_tag: Option<String>,
}
impl UpdateCommand {
fn requested_version(&self) -> Option<&str> {
self.target_version
.as_deref()
.or(self.target_tag.as_deref())
}
}
struct DownloadedAsset {
_temp_dir: TempDir,
archive_path: PathBuf,
}
#[derive(Debug, Deserialize, Clone)]
struct UpdateManifest {
version: String,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
pub_date: Option<String>,
platforms: BTreeMap<String, UpdatePlatformEntry>,
}
#[derive(Debug, Deserialize, Clone)]
struct UpdatePlatformEntry {
url: String,
signature: String,
#[serde(default)]
variants: BTreeMap<String, UpdatePlatformVariant>,
}
#[derive(Debug, Deserialize, Clone)]
struct UpdatePlatformVariant {
url: String,
signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ManifestAsset {
url: String,
signature: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinuxLibcPreference {
Auto,
Musl,
Glibc,
}
#[derive(Debug, Deserialize, Clone)]
struct ReleaseInfo {
tag_name: String,
#[serde(default)]
assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Deserialize, Clone)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
#[serde(default)]
digest: Option<String>,
}
#[derive(Debug, Clone)]
enum ResolvedRelease {
Manifest {
target_tag: String,
manifest: UpdateManifest,
},
Legacy {
target_tag: String,
release: ReleaseInfo,
},
}
#[derive(Debug)]
enum ManifestFetchError {
NotFound,
Invalid(AppError),
}
impl From<AppError> for ManifestFetchError {
fn from(value: AppError) -> Self {
Self::Invalid(value)
}
}
impl ResolvedRelease {
fn target_tag(&self) -> &str {
match self {
Self::Manifest { target_tag, .. } | Self::Legacy { target_tag, .. } => target_tag,
}
}
}
pub fn execute(cmd: UpdateCommand) -> Result<(), AppError> {
let runtime = create_runtime()?;
runtime.block_on(execute_async(cmd))
}
async fn execute_async(cmd: UpdateCommand) -> Result<(), AppError> {
let current_version = env!("CARGO_PKG_VERSION");
let requested_version = cmd.requested_version();
let explicit_version = requested_version.is_some_and(|v| !v.trim().is_empty());
let client = create_http_client()?;
let release = resolve_target_release(&client, REPO_URL, requested_version).await?;
let target_tag = release.target_tag().to_string();
let target_version = target_tag.trim_start_matches('v');
if target_version == current_version {
println!(
"{}",
info(&format!("Already on latest version: v{current_version}"))
);
return Ok(());
}
if should_skip_implicit_downgrade(current_version, target_version, explicit_version) {
println!(
"{}",
info(&format!(
"Current version v{current_version} is newer than target {target_tag}; skipping automatic downgrade. Use `cc-switch update {target_tag}` to force."
))
);
return Ok(());
}
println!(
"{}",
highlight(&format!("Current version: v{current_version}"))
);
println!("{}", highlight(&format!("Updating to: {target_tag}")));
let downloaded_asset = match release {
ResolvedRelease::Manifest { manifest, .. } => {
let asset = select_current_manifest_asset(&manifest)?;
println!("{}", info(&format!("Downloading: {}", asset.url)));
println!("{}", info("Verifying updater signature."));
let (downloaded_asset, _) =
download_manifest_release_asset(&client, &manifest, None).await?;
downloaded_asset
}
ResolvedRelease::Legacy {
target_tag,
release,
} => {
let expected_asset_names = current_release_asset_candidates()?;
let release_asset = select_release_asset_from_candidates(
&release.assets,
&target_tag,
&expected_asset_names,
)
.ok_or_else(|| {
AppError::Message(format!(
"Release {target_tag} does not include any expected assets {:?} (or compatible tagged variants).",
expected_asset_names
))
})?;
let checksum_url = release_checksums_url(REPO_URL, &target_tag)?;
println!(
"{}",
info(&format!(
"Downloading: {}",
release_asset.browser_download_url.as_str()
))
);
if release_asset.digest.is_some() {
println!(
"{}",
info("Verifying checksum from release metadata digest.")
);
} else {
println!("{}", info(&format!("Verifying checksum: {checksum_url}")));
}
let (downloaded_asset, _) =
download_legacy_release_asset(&client, &target_tag, Some(&release), None).await?;
downloaded_asset
}
};
let extracted_binary = extract_binary(&downloaded_asset.archive_path)?;
replace_current_binary(&extracted_binary)?;
println!(
"{}",
success(&format!("Updated successfully to {target_tag}"))
);
println!(
"{}",
info("Run `cc-switch --version` to verify the installed version.")
);
println!(
"{}",
info(
"If you want to install or refresh managed bash/zsh completions, run: `cc-switch completions install`."
)
);
Ok(())
}
fn create_runtime() -> Result<tokio::runtime::Runtime, AppError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| AppError::Message(format!("Failed to create runtime: {e}")))
}
fn create_http_client() -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS))
.build()
.map_err(|e| AppError::Message(format!("Failed to initialize HTTP client: {e}")))
}
fn update_manifest_url(repo_url: &str, tag: Option<&str>) -> Result<Url, AppError> {
match tag {
Some(tag) => release_page_url(
repo_url,
&format!("download/{tag}/{LATEST_MANIFEST_FILE_NAME}"),
),
None => release_page_url(
repo_url,
&format!("latest/download/{LATEST_MANIFEST_FILE_NAME}"),
),
}
}
async fn fetch_update_manifest(
client: &reqwest::Client,
repo_url: &str,
tag: Option<&str>,
) -> Result<UpdateManifest, ManifestFetchError> {
let url = update_manifest_url(repo_url, tag)?;
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|e| {
ManifestFetchError::Invalid(AppError::Message(format!(
"Failed to query update manifest: {e}"
)))
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(ManifestFetchError::NotFound);
}
response
.error_for_status()
.map_err(|e| {
ManifestFetchError::Invalid(AppError::Message(format!(
"Update manifest returned error: {e}"
)))
})?
.json::<UpdateManifest>()
.await
.map_err(|e| {
ManifestFetchError::Invalid(AppError::Message(format!(
"Failed to parse update manifest: {e}"
)))
})
}
fn manifest_target_tag(manifest: &UpdateManifest) -> Result<String, AppError> {
let tag = normalize_tag(manifest.version.trim());
validate_target_tag(&tag)?;
Ok(tag)
}
fn validate_requested_manifest_tag(
manifest: &UpdateManifest,
requested_tag: &str,
) -> Result<(), AppError> {
let manifest_tag = manifest_target_tag(manifest)?;
if manifest_tag != requested_tag {
return Err(AppError::Message(format!(
"Update manifest version {manifest_tag} does not match requested version {requested_tag}."
)));
}
Ok(())
}
fn current_platform_key() -> Result<&'static str, AppError> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("macos", "x86_64") => Ok("darwin-x86_64"),
("macos", "aarch64") => Ok("darwin-aarch64"),
("linux", "x86_64") => Ok("linux-x86_64"),
("linux", "aarch64") => Ok("linux-aarch64"),
("windows", "x86_64") => Ok("windows-x86_64"),
_ => Err(AppError::Message(format!(
"Self-update is not supported for platform {os}/{arch}."
))),
}
}
fn linux_libc_preference() -> Result<LinuxLibcPreference, AppError> {
let raw = std::env::var("CC_SWITCH_LINUX_LIBC").unwrap_or_else(|_| "auto".to_string());
match raw.trim() {
"" | "auto" | "AUTO" => Ok(LinuxLibcPreference::Auto),
"musl" | "MUSL" => Ok(LinuxLibcPreference::Musl),
"glibc" | "GLIBC" | "gnu" | "GNU" => Ok(LinuxLibcPreference::Glibc),
other => Err(AppError::Message(format!(
"Unsupported CC_SWITCH_LINUX_LIBC='{other}'. Expected auto, musl, or glibc."
))),
}
}
fn push_manifest_asset(candidates: &mut Vec<ManifestAsset>, asset: ManifestAsset) {
if !candidates.contains(&asset) {
candidates.push(asset);
}
}
fn asset_looks_like_musl(url: &str) -> bool {
url.contains("-musl")
}
fn select_manifest_asset(
manifest: &UpdateManifest,
platform_key: &str,
preference: LinuxLibcPreference,
) -> Result<ManifestAsset, AppError> {
manifest_asset_candidates(manifest, platform_key, preference)?
.into_iter()
.next()
.ok_or_else(|| {
AppError::Message("Update manifest does not contain a usable asset.".to_string())
})
}
fn manifest_asset_candidates(
manifest: &UpdateManifest,
platform_key: &str,
preference: LinuxLibcPreference,
) -> Result<Vec<ManifestAsset>, AppError> {
let entry = manifest.platforms.get(platform_key).ok_or_else(|| {
AppError::Message(format!(
"Update manifest does not provide platform entry '{platform_key}'."
))
})?;
let primary = ManifestAsset {
url: entry.url.clone(),
signature: entry.signature.clone(),
};
if !platform_key.starts_with("linux-") {
return Ok(vec![primary]);
}
let musl_variant = entry.variants.get("musl").map(|variant| ManifestAsset {
url: variant.url.clone(),
signature: variant.signature.clone(),
});
let glibc_variant = entry.variants.get("glibc").map(|variant| ManifestAsset {
url: variant.url.clone(),
signature: variant.signature.clone(),
});
let mut candidates = Vec::new();
match preference {
LinuxLibcPreference::Auto => {
push_manifest_asset(&mut candidates, primary);
if let Some(asset) = glibc_variant {
push_manifest_asset(&mut candidates, asset);
}
if let Some(asset) = musl_variant {
push_manifest_asset(&mut candidates, asset);
}
}
LinuxLibcPreference::Musl => {
if let Some(asset) = musl_variant {
push_manifest_asset(&mut candidates, asset);
} else if asset_looks_like_musl(&primary.url) {
push_manifest_asset(&mut candidates, primary.clone());
} else {
return Err(AppError::Message(format!(
"Update manifest does not provide a musl variant for platform '{platform_key}'."
)));
}
}
LinuxLibcPreference::Glibc => {
if let Some(asset) = glibc_variant {
push_manifest_asset(&mut candidates, asset);
} else if !asset_looks_like_musl(&primary.url) {
push_manifest_asset(&mut candidates, primary.clone());
} else {
return Err(AppError::Message(format!(
"Update manifest does not provide a glibc variant for platform '{platform_key}'."
)));
}
push_manifest_asset(&mut candidates, primary);
if let Some(asset) = musl_variant {
push_manifest_asset(&mut candidates, asset);
}
}
}
Ok(candidates)
}
fn select_current_manifest_asset(manifest: &UpdateManifest) -> Result<ManifestAsset, AppError> {
select_manifest_asset(manifest, current_platform_key()?, linux_libc_preference()?)
}
fn release_asset_candidates_for_platform(
os: &str,
arch: &str,
preference: LinuxLibcPreference,
) -> Result<Vec<String>, AppError> {
let names = match (os, arch) {
("macos", "x86_64") => vec![
"cc-switch-tui-darwin-universal.tar.gz".to_string(),
"cc-switch-tui-darwin-x64.tar.gz".to_string(),
],
("macos", "aarch64") => vec![
"cc-switch-tui-darwin-universal.tar.gz".to_string(),
"cc-switch-tui-darwin-arm64.tar.gz".to_string(),
],
("linux", "x86_64") => match preference {
LinuxLibcPreference::Auto => vec![
"cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
"cc-switch-tui-linux-x64.tar.gz".to_string(),
],
LinuxLibcPreference::Musl => vec!["cc-switch-tui-linux-x64-musl.tar.gz".to_string()],
LinuxLibcPreference::Glibc => vec![
"cc-switch-tui-linux-x64.tar.gz".to_string(),
"cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
],
},
("linux", "aarch64") => match preference {
LinuxLibcPreference::Auto => vec![
"cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
"cc-switch-tui-linux-arm64.tar.gz".to_string(),
],
LinuxLibcPreference::Musl => {
vec!["cc-switch-tui-linux-arm64-musl.tar.gz".to_string()]
}
LinuxLibcPreference::Glibc => vec![
"cc-switch-tui-linux-arm64.tar.gz".to_string(),
"cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
],
},
("windows", "x86_64") => vec!["cc-switch-tui-windows-x64.zip".to_string()],
_ => {
return Err(AppError::Message(format!(
"Self-update is not supported for platform {os}/{arch}."
)))
}
};
Ok(names)
}
fn current_release_asset_candidates() -> Result<Vec<String>, AppError> {
release_asset_candidates_for_platform(
std::env::consts::OS,
std::env::consts::ARCH,
linux_libc_preference()?,
)
}
fn select_release_asset_from_candidates<'a>(
assets: &'a [ReleaseAsset],
target_tag: &str,
expected_asset_names: &[String],
) -> Option<&'a ReleaseAsset> {
expected_asset_names.iter().find_map(|expected_asset_name| {
select_release_asset(assets, target_tag, expected_asset_name)
})
}
fn parse_public_key(public_key_text: &str) -> Result<PublicKey, AppError> {
PublicKey::decode(public_key_text.trim())
.or_else(|_| PublicKey::from_base64(public_key_text.trim()))
.map_err(|e| AppError::Message(format!("Invalid updater public key: {e}")))
}
fn verify_minisign_signature(
payload: &[u8],
signature_text: &str,
public_key_text: &str,
) -> Result<(), AppError> {
let public_key = parse_public_key(public_key_text)?;
let signature = Signature::decode(signature_text.trim())
.map_err(|e| AppError::Message(format!("Invalid updater signature: {e}")))?;
public_key
.verify(payload, &signature, false)
.map_err(|e| AppError::Message(format!("Updater signature verification failed: {e}")))
}
fn verify_downloaded_asset_signature(
archive_path: &Path,
signature_text: &str,
) -> Result<(), AppError> {
let payload = fs::read(archive_path).map_err(|e| AppError::io(archive_path, e))?;
verify_minisign_signature(&payload, signature_text, UPDATER_PUBLIC_KEY)
}
fn asset_name_from_url(url: &str) -> Result<String, AppError> {
let parsed = Url::parse(url)
.map_err(|e| AppError::Message(format!("Invalid asset URL '{url}': {e}")))?;
let asset_name = parsed
.path_segments()
.and_then(|segments| segments.last())
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Message(format!("Asset URL has no file name: {url}")))?;
sanitized_asset_file_name(asset_name).map(str::to_string)
}
async fn download_manifest_release_asset(
client: &reqwest::Client,
manifest: &UpdateManifest,
on_progress: Option<&dyn Fn(u64, Option<u64>)>,
) -> Result<(DownloadedAsset, ManifestAsset), AppError> {
let assets =
manifest_asset_candidates(manifest, current_platform_key()?, linux_libc_preference()?)?;
let mut last_error = None;
for asset in assets {
let asset_name = asset_name_from_url(&asset.url)?;
match download_release_asset(client, &asset.url, &asset_name, on_progress).await {
Ok(downloaded_asset) => {
verify_downloaded_asset_signature(
&downloaded_asset.archive_path,
&asset.signature,
)?;
return Ok((downloaded_asset, asset));
}
Err(err) => last_error = Some(err),
}
}
Err(last_error.unwrap_or_else(|| {
AppError::Message("Update manifest did not produce a downloadable asset.".to_string())
}))
}
async fn download_legacy_release_asset(
client: &reqwest::Client,
target_tag: &str,
release: Option<&ReleaseInfo>,
on_progress: Option<&dyn Fn(u64, Option<u64>)>,
) -> Result<(DownloadedAsset, ReleaseAsset), AppError> {
let release = match release {
Some(release) => release.clone(),
None => fetch_release_by_tag(client, REPO_URL, target_tag).await?,
};
let expected_asset_names = current_release_asset_candidates()?;
let release_asset = select_release_asset_from_candidates(
&release.assets,
target_tag,
&expected_asset_names,
)
.ok_or_else(|| {
AppError::Message(format!(
"Release {target_tag} does not include any expected assets {:?} (or compatible tagged variants).",
expected_asset_names
))
})?
.clone();
let downloaded_asset = download_release_asset(
client,
release_asset.browser_download_url.as_str(),
release_asset.name.as_str(),
on_progress,
)
.await?;
verify_asset_checksum(
client,
&downloaded_asset.archive_path,
target_tag,
&release_asset,
)
.await?;
Ok((downloaded_asset, release_asset))
}
async fn resolve_target_release(
client: &reqwest::Client,
repo_url: &str,
version: Option<&str>,
) -> Result<ResolvedRelease, AppError> {
if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
let target_tag = normalize_tag(version);
validate_target_tag(&target_tag)?;
match fetch_update_manifest(client, repo_url, Some(&target_tag)).await {
Ok(manifest) => {
validate_requested_manifest_tag(&manifest, &target_tag)?;
return Ok(ResolvedRelease::Manifest {
target_tag,
manifest,
});
}
Err(ManifestFetchError::NotFound) => {}
Err(ManifestFetchError::Invalid(err)) => return Err(err),
}
return Ok(ResolvedRelease::Legacy {
target_tag: target_tag.clone(),
release: fetch_release_by_tag(client, repo_url, &target_tag).await?,
});
}
match fetch_update_manifest(client, repo_url, None).await {
Ok(manifest) => {
return Ok(ResolvedRelease::Manifest {
target_tag: manifest_target_tag(&manifest)?,
manifest,
});
}
Err(ManifestFetchError::NotFound) => {}
Err(ManifestFetchError::Invalid(err)) => return Err(err),
}
let target_tag = fetch_latest_release_tag(client, repo_url).await?;
Ok(ResolvedRelease::Legacy {
target_tag: target_tag.clone(),
release: fetch_release_by_tag(client, repo_url, &target_tag).await?,
})
}
async fn resolve_target_tag(
client: &reqwest::Client,
version: Option<&str>,
) -> Result<String, AppError> {
let tag = match version.map(str::trim).filter(|v| !v.is_empty()) {
Some(version) => normalize_tag(version),
None => fetch_latest_release_tag(client, REPO_URL).await?,
};
validate_target_tag(&tag)?;
Ok(tag)
}
fn validate_target_tag(tag: &str) -> Result<(), AppError> {
if !tag.starts_with('v') {
return Err(AppError::Message(format!(
"Invalid version tag '{tag}': must start with 'v'."
)));
}
if tag.len() > 64 {
return Err(AppError::Message(format!(
"Invalid version tag '{tag}': too long."
)));
}
if tag.contains('/') || tag.contains('\\') || tag.contains("..") {
return Err(AppError::Message(format!(
"Invalid version tag '{tag}': contains forbidden path characters."
)));
}
if !tag
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_')
{
return Err(AppError::Message(format!(
"Invalid version tag '{tag}': only [A-Za-z0-9._-] allowed."
)));
}
Ok(())
}
fn normalize_tag(version: &str) -> String {
if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
}
async fn fetch_latest_release_tag(
client: &reqwest::Client,
repo_url: &str,
) -> Result<String, AppError> {
let api_url = release_api_url(repo_url, "latest")?;
let response = client
.get(api_url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT)
.send()
.await
.map_err(|e| AppError::Message(format!("Failed to query latest release: {e}")))?;
if matches!(
response.status(),
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS
) {
return fetch_latest_release_tag_from_release_page(client, repo_url).await;
}
let release = response
.error_for_status()
.map_err(|e| AppError::Message(format!("Release API returned error: {e}")))?
.json::<ReleaseInfo>()
.await
.map_err(|e| AppError::Message(format!("Failed to parse latest release response: {e}")))?;
Ok(release.tag_name)
}
async fn fetch_release_by_tag(
client: &reqwest::Client,
repo_url: &str,
tag: &str,
) -> Result<ReleaseInfo, AppError> {
let api_url = release_api_url(repo_url, &format!("tags/{tag}"))?;
client
.get(api_url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.header(reqwest::header::ACCEPT, GITHUB_API_ACCEPT)
.send()
.await
.map_err(|e| AppError::Message(format!("Failed to query release {tag}: {e}")))?
.error_for_status()
.map_err(|e| AppError::Message(format!("Release API returned error for {tag}: {e}")))?
.json::<ReleaseInfo>()
.await
.map_err(|e| AppError::Message(format!("Failed to parse release response for {tag}: {e}")))
}
async fn fetch_latest_release_tag_from_release_page(
client: &reqwest::Client,
repo_url: &str,
) -> Result<String, AppError> {
let latest_url = release_page_url(repo_url, "latest")?;
let response = client
.get(latest_url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|e| AppError::Message(format!("Failed to query latest release page: {e}")))?
.error_for_status()
.map_err(|e| AppError::Message(format!("Latest release page returned error: {e}")))?;
extract_release_tag_from_url(response.url()).ok_or_else(|| {
AppError::Message(format!(
"Failed to resolve latest release tag from {}.",
response.url()
))
})
}
fn release_page_url(repo_url: &str, suffix: &str) -> Result<Url, AppError> {
let repo_url = Url::parse(repo_url)
.map_err(|e| AppError::Message(format!("Invalid repository URL '{repo_url}': {e}")))?;
let path = repo_url.path().trim_matches('/');
let mut parts = path.split('/');
let owner = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
AppError::Message(format!(
"Repository URL must include owner and repo: {repo_url}"
))
})?;
let repo = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
AppError::Message(format!(
"Repository URL must include owner and repo: {repo_url}"
))
})?;
if parts.next().is_some() {
return Err(AppError::Message(format!(
"Repository URL must be in '<host>/<owner>/<repo>' format: {repo_url}"
)));
}
let repo = repo.strip_suffix(".git").unwrap_or(repo);
let mut release_url = repo_url.clone();
release_url.set_path(&format!("/{owner}/{repo}/releases/{suffix}"));
release_url.set_query(None);
release_url.set_fragment(None);
Ok(release_url)
}
fn release_api_url(repo_url: &str, suffix: &str) -> Result<Url, AppError> {
let repo_url = Url::parse(repo_url)
.map_err(|e| AppError::Message(format!("Invalid repository URL '{repo_url}': {e}")))?;
let host = repo_url
.host_str()
.ok_or_else(|| AppError::Message(format!("Repository URL is missing host: {repo_url}")))?;
let path = repo_url.path().trim_matches('/');
let mut parts = path.split('/');
let owner = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
AppError::Message(format!(
"Repository URL must include owner and repo: {repo_url}"
))
})?;
let repo = parts.next().filter(|s| !s.is_empty()).ok_or_else(|| {
AppError::Message(format!(
"Repository URL must include owner and repo: {repo_url}"
))
})?;
if parts.next().is_some() {
return Err(AppError::Message(format!(
"Repository URL must be in '<host>/<owner>/<repo>' format: {repo_url}"
)));
}
let repo = repo.strip_suffix(".git").unwrap_or(repo);
let api_path = if host == "github.com" {
format!("/repos/{owner}/{repo}/releases/{suffix}")
} else {
format!("/api/v3/repos/{owner}/{repo}/releases/{suffix}")
};
let mut api_url = repo_url.clone();
if host == "github.com" {
api_url
.set_host(Some("api.github.com"))
.map_err(|_| AppError::Message("Failed to set GitHub API host.".to_string()))?;
}
api_url.set_path(&api_path);
api_url.set_query(None);
api_url.set_fragment(None);
Ok(api_url)
}
fn release_checksums_url(repo_url: &str, tag: &str) -> Result<Url, AppError> {
release_page_url(repo_url, &format!("download/{tag}/{CHECKSUMS_FILE_NAME}"))
}
fn extract_release_tag_from_url(url: &Url) -> Option<String> {
let segments = url.path_segments()?.collect::<Vec<_>>();
segments
.windows(3)
.find(|window| window[0] == "releases" && window[1] == "tag")
.map(|window| window[2].to_string())
}
fn tagged_asset_name(tag: &str, asset_name: &str) -> String {
if let Some(suffix) = asset_name.strip_prefix("cc-switch-tui-") {
return format!("cc-switch-tui-{tag}-{suffix}");
}
asset_name.to_string()
}
fn release_asset_names(tag: &str, asset_name: &str) -> Vec<String> {
let tagged = tagged_asset_name(tag, asset_name);
if tagged == asset_name {
vec![asset_name.to_string()]
} else {
vec![asset_name.to_string(), tagged]
}
}
fn select_release_asset<'a>(
assets: &'a [ReleaseAsset],
target_tag: &str,
expected_asset_name: &str,
) -> Option<&'a ReleaseAsset> {
let expected_names = release_asset_names(target_tag, expected_asset_name);
expected_names
.iter()
.find_map(|expected_name| assets.iter().find(|asset| asset.name == *expected_name))
}
async fn download_release_asset(
client: &reqwest::Client,
url: &str,
asset_name: &str,
on_progress: Option<&dyn Fn(u64, Option<u64>)>,
) -> Result<DownloadedAsset, AppError> {
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|e| AppError::Message(format!("Failed to download release asset: {e}")))?;
let mut response = response
.error_for_status()
.map_err(|e| AppError::Message(format!("Release asset request failed: {e}")))?;
let content_length = response.content_length();
if let Some(cl) = content_length {
validate_download_size_limit(cl, asset_name)?;
}
let temp_dir = tempfile::tempdir()
.map_err(|e| AppError::Message(format!("Failed to create temp directory: {e}")))?;
let file_name = sanitized_asset_file_name(asset_name)?;
let archive_path = temp_dir.path().join(file_name);
let mut output = fs::File::create(&archive_path).map_err(|e| AppError::io(&archive_path, e))?;
let mut downloaded_bytes = 0_u64;
let mut last_reported = 0_u64;
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| AppError::Message(format!("Failed to read release asset chunk: {e}")))?
{
downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64);
validate_download_size_limit(downloaded_bytes, asset_name)?;
output
.write_all(&chunk)
.map_err(|e| AppError::io(&archive_path, e))?;
if let Some(cb) = on_progress {
if downloaded_bytes - last_reported >= 64 * 1024 {
cb(downloaded_bytes, content_length);
last_reported = downloaded_bytes;
}
}
}
if let Some(cb) = on_progress {
cb(downloaded_bytes, content_length);
}
Ok(DownloadedAsset {
_temp_dir: temp_dir,
archive_path,
})
}
fn sanitized_asset_file_name(asset_name: &str) -> Result<&str, AppError> {
Path::new(asset_name)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| AppError::Message(format!("Invalid asset name: {asset_name}")))
}
fn validate_download_size_limit(size_bytes: u64, asset_name: &str) -> Result<(), AppError> {
if size_bytes <= MAX_RELEASE_ASSET_SIZE_BYTES {
return Ok(());
}
let max_mb = MAX_RELEASE_ASSET_SIZE_BYTES / (1024 * 1024);
let size_mb = size_bytes / (1024 * 1024);
Err(AppError::Message(format!(
"Release asset '{asset_name}' is too large ({size_mb} MB). Maximum allowed size is {max_mb} MB."
)))
}
async fn verify_asset_checksum(
client: &reqwest::Client,
archive_path: &Path,
target_tag: &str,
release_asset: &ReleaseAsset,
) -> Result<(), AppError> {
let actual = compute_sha256_hex(archive_path)?;
let expected = if let Some(expected) = release_asset
.digest
.as_deref()
.and_then(parse_sha256_digest)
{
expected
} else {
let checksum_url = release_checksums_url(REPO_URL, target_tag)?;
let checksum_content = download_text(client, checksum_url.as_str()).await?;
parse_checksum_for_asset(&checksum_content, release_asset.name.as_str())?
};
if actual != expected {
return Err(AppError::Message(format!(
"Checksum mismatch for {}: expected {expected}, got {actual}.",
release_asset.name
)));
}
Ok(())
}
fn compute_sha256_hex(path: &Path) -> Result<String, AppError> {
let mut file = fs::File::open(path).map_err(|e| AppError::io(path, e))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher).map_err(|e| AppError::io(path, e))?;
Ok(format!("{:x}", hasher.finalize()))
}
fn should_skip_implicit_downgrade(
current_version: &str,
target_version: &str,
explicit_version: bool,
) -> bool {
if explicit_version {
return false;
}
let Ok(current) = Version::parse(current_version) else {
return false;
};
let Ok(target) = Version::parse(target_version) else {
return false;
};
target < current
}
async fn download_text(client: &reqwest::Client, url: &str) -> Result<String, AppError> {
let response = client
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|e| AppError::Message(format!("Failed to download checksum file: {e}")))?;
response
.error_for_status()
.map_err(|e| AppError::Message(format!("Checksum file request failed: {e}")))?
.text()
.await
.map_err(|e| AppError::Message(format!("Failed to read checksum file body: {e}")))
}
fn parse_checksum_for_asset(checksum_content: &str, asset_name: &str) -> Result<String, AppError> {
let expected = checksum_content
.lines()
.filter_map(|line| {
let line = line.trim_end();
if line.is_empty() {
return None;
}
let (hash, file) = parse_sha256sum_line(line)?;
if file == asset_name {
Some(hash.to_ascii_lowercase())
} else {
None
}
})
.next();
expected.ok_or_else(|| {
AppError::Message(format!(
"Unable to find SHA256 for {asset_name} in {CHECKSUMS_FILE_NAME}."
))
})
}
fn parse_sha256sum_line(line: &str) -> Option<(&str, &str)> {
if line.len() < 66 {
return None;
}
let (hash, remainder) = line.split_at(64);
if !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
if let Some(file) = remainder
.strip_prefix(" ")
.or_else(|| remainder.strip_prefix(" *"))
{
return Some((hash, file));
}
None
}
fn parse_sha256_digest(digest: &str) -> Option<String> {
let digest = digest.strip_prefix("sha256:")?;
if digest.len() != 64 || !digest.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
Some(digest.to_ascii_lowercase())
}
fn extract_binary(archive_path: &Path) -> Result<PathBuf, AppError> {
let extract_dir = archive_path
.parent()
.ok_or_else(|| AppError::Message("Invalid archive path".to_string()))?
.join("extracted");
fs::create_dir_all(&extract_dir).map_err(|e| AppError::io(&extract_dir, e))?;
if cfg!(windows) {
extract_zip_binary(archive_path, &extract_dir)
} else {
extract_tar_binary(archive_path, &extract_dir)
}
}
#[cfg(not(windows))]
fn extract_tar_binary(archive_path: &Path, extract_dir: &Path) -> Result<PathBuf, AppError> {
let file = fs::File::open(archive_path).map_err(|e| AppError::io(archive_path, e))?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|e| AppError::Message(format!("Failed to read archive entries: {e}")))?;
for entry in entries {
let mut entry =
entry.map_err(|e| AppError::Message(format!("Failed to read archive entry: {e}")))?;
if !entry.header().entry_type().is_file() {
continue;
}
let entry_path = entry
.path()
.map_err(|e| AppError::Message(format!("Failed to inspect archive entry path: {e}")))?;
if entry_path.file_name().and_then(|name| name.to_str()) != Some(BINARY_NAME) {
continue;
}
let binary_path = extract_dir.join(BINARY_NAME);
let mut output =
fs::File::create(&binary_path).map_err(|e| AppError::io(&binary_path, e))?;
std::io::copy(&mut entry, &mut output)
.map_err(|e| AppError::Message(format!("Failed to unpack binary from TAR: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(&binary_path, perms).map_err(|e| AppError::io(&binary_path, e))?;
}
return Ok(binary_path);
}
Err(AppError::Message(format!(
"Extracted archive does not contain expected binary: {BINARY_NAME}"
)))
}
#[cfg(not(windows))]
fn extract_zip_binary(_archive_path: &Path, _extract_dir: &Path) -> Result<PathBuf, AppError> {
Err(AppError::Message(
"ZIP extraction is only supported on Windows.".to_string(),
))
}
#[cfg(windows)]
fn extract_zip_binary(archive_path: &Path, extract_dir: &Path) -> Result<PathBuf, AppError> {
let file = fs::File::open(archive_path).map_err(|e| AppError::io(archive_path, e))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| AppError::Message(format!("Failed to open ZIP archive: {e}")))?;
let binary_filename = format!("{BINARY_NAME}.exe");
let mut entry = zip.by_name(&binary_filename).map_err(|_| {
AppError::Message(format!("ZIP archive does not contain {binary_filename}"))
})?;
let binary_path = extract_dir.join(binary_filename);
let mut output = fs::File::create(&binary_path).map_err(|e| AppError::io(&binary_path, e))?;
std::io::copy(&mut entry, &mut output)
.map_err(|e| AppError::Message(format!("Failed to extract binary from ZIP: {e}")))?;
Ok(binary_path)
}
#[cfg(windows)]
fn extract_tar_binary(_archive_path: &Path, _extract_dir: &Path) -> Result<PathBuf, AppError> {
Err(AppError::Message(
"TAR extraction is not supported on Windows.".to_string(),
))
}
fn replace_current_binary(new_binary_path: &Path) -> Result<(), AppError> {
#[cfg(windows)]
{
return self_replace::self_replace(new_binary_path).map_err(|e| {
AppError::Message(format!(
"Failed to replace running executable on Windows: {e}"
))
});
}
#[cfg(not(windows))]
{
let current_binary = std::env::current_exe().map_err(|e| {
AppError::Message(format!("Failed to resolve current executable path: {e}"))
})?;
let parent = current_binary.parent().ok_or_else(|| {
AppError::Message("Current executable path has no parent directory.".to_string())
})?;
let staged_binary = parent.join(format!("{BINARY_NAME}.new"));
remove_file_if_present(&staged_binary)?;
fs::copy(new_binary_path, &staged_binary)
.map_err(|e| map_update_permission_error(&staged_binary, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(&staged_binary, perms)
.map_err(|e| map_update_permission_error(&staged_binary, e))?;
}
fs::rename(&staged_binary, ¤t_binary)
.map_err(|e| map_update_permission_error(¤t_binary, e))?;
Ok(())
}
}
fn remove_file_if_present(path: &Path) -> Result<(), AppError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(AppError::io(path, err)),
}
}
fn map_update_permission_error(target: &Path, err: std::io::Error) -> AppError {
if err.kind() == std::io::ErrorKind::PermissionDenied {
return AppError::Message(format!(
"Permission denied while updating {}. Re-run with elevated privileges (for example: sudo cc-switch update), or use your package manager update command.",
target.display()
));
}
AppError::io(target, err)
}
pub(crate) struct UpdateCheckInfo {
pub current_version: String,
pub target_tag: String,
pub is_already_latest: bool,
pub is_downgrade: bool,
}
pub(crate) async fn check_for_update() -> Result<UpdateCheckInfo, AppError> {
let current_version = env!("CARGO_PKG_VERSION");
let client = create_http_client()?;
let target_tag = resolve_target_release(&client, REPO_URL, None)
.await?
.target_tag()
.to_string();
let target_version = target_tag.trim_start_matches('v');
let is_already_latest = target_version == current_version;
let is_downgrade = should_skip_implicit_downgrade(current_version, target_version, false);
Ok(UpdateCheckInfo {
current_version: current_version.to_string(),
target_tag,
is_already_latest,
is_downgrade,
})
}
pub(crate) async fn download_and_apply(
target_tag: &str,
on_progress: impl Fn(u64, Option<u64>),
) -> Result<(), AppError> {
let client = create_http_client()?;
let release = resolve_target_release(&client, REPO_URL, Some(target_tag)).await?;
let downloaded_asset = match release {
ResolvedRelease::Manifest { manifest, .. } => {
let (downloaded_asset, _) =
download_manifest_release_asset(&client, &manifest, Some(&on_progress)).await?;
downloaded_asset
}
ResolvedRelease::Legacy {
target_tag,
release,
} => {
let (downloaded_asset, _) = download_legacy_release_asset(
&client,
&target_tag,
Some(&release),
Some(&on_progress),
)
.await?;
downloaded_asset
}
};
let extracted_binary = extract_binary(&downloaded_asset.archive_path)?;
replace_current_binary(&extracted_binary)?;
Ok(())
}
#[cfg(test)]
mod tests;