|
| 1 | +//! Self-upgrade command with SHA256 integrity verification. |
| 2 | +//! |
| 3 | +//! Downloads the latest release binary from GitHub, verifies its SHA256 |
| 4 | +//! against the release's `checksums.txt`, and replaces the running binary. |
| 5 | +
|
| 6 | +use std::io::{Read, Write}; |
| 7 | +use std::path::PathBuf; |
| 8 | + |
| 9 | +use anyhow::{anyhow, bail, Context, Result}; |
| 10 | +use sha2::{Digest, Sha256}; |
| 11 | + |
| 12 | +const REPO: &str = "rtk-ai/icm"; |
| 13 | +const BINARY_NAME: &str = "icm"; |
| 14 | + |
| 15 | +/// Detect the target triple for this platform. |
| 16 | +fn detect_target() -> Result<(&'static str, &'static str)> { |
| 17 | + let os = std::env::consts::OS; |
| 18 | + let arch = std::env::consts::ARCH; |
| 19 | + |
| 20 | + let target_suffix = match os { |
| 21 | + "macos" => "apple-darwin", |
| 22 | + "linux" => "unknown-linux-gnu", |
| 23 | + "windows" => "pc-windows-msvc", |
| 24 | + _ => bail!("Unsupported OS: {os}"), |
| 25 | + }; |
| 26 | + |
| 27 | + let arch = match arch { |
| 28 | + "x86_64" => "x86_64", |
| 29 | + "aarch64" => "aarch64", |
| 30 | + _ => bail!("Unsupported architecture: {arch}"), |
| 31 | + }; |
| 32 | + |
| 33 | + let ext = if os == "windows" { "zip" } else { "tar.gz" }; |
| 34 | + let target = Box::leak(format!("{arch}-{target_suffix}").into_boxed_str()); |
| 35 | + Ok((target, ext)) |
| 36 | +} |
| 37 | + |
| 38 | +/// Fetch the latest release tag from the GitHub API. |
| 39 | +fn fetch_latest_version() -> Result<String> { |
| 40 | + let url = format!("https://api.github.com/repos/{REPO}/releases/latest"); |
| 41 | + let resp = ureq::get(&url) |
| 42 | + .set("User-Agent", "icm-upgrader") |
| 43 | + .set("Accept", "application/vnd.github+json") |
| 44 | + .call() |
| 45 | + .context("failed to fetch latest release")?; |
| 46 | + |
| 47 | + let json: serde_json::Value = resp.into_json().context("invalid API response")?; |
| 48 | + let tag = json |
| 49 | + .get("tag_name") |
| 50 | + .and_then(|v| v.as_str()) |
| 51 | + .ok_or_else(|| anyhow!("missing tag_name in API response"))?; |
| 52 | + Ok(tag.to_string()) |
| 53 | +} |
| 54 | + |
| 55 | +/// Download a URL to a byte vector with size tracking. |
| 56 | +fn download_bytes(url: &str) -> Result<Vec<u8>> { |
| 57 | + let resp = ureq::get(url) |
| 58 | + .set("User-Agent", "icm-upgrader") |
| 59 | + .call() |
| 60 | + .with_context(|| format!("failed to download {url}"))?; |
| 61 | + |
| 62 | + let mut buf = Vec::new(); |
| 63 | + resp.into_reader() |
| 64 | + .read_to_end(&mut buf) |
| 65 | + .context("failed to read response body")?; |
| 66 | + Ok(buf) |
| 67 | +} |
| 68 | + |
| 69 | +/// Compute SHA256 of bytes as lowercase hex. |
| 70 | +fn sha256_hex(data: &[u8]) -> String { |
| 71 | + let mut hasher = Sha256::new(); |
| 72 | + hasher.update(data); |
| 73 | + format!("{:x}", hasher.finalize()) |
| 74 | +} |
| 75 | + |
| 76 | +/// Parse the expected SHA256 for a file from a `sha256sum` output. |
| 77 | +/// Format per line: `<64-hex> <filename>`. |
| 78 | +fn parse_expected_sha(checksums: &str, filename: &str) -> Result<String> { |
| 79 | + for line in checksums.lines() { |
| 80 | + let parts: Vec<&str> = line.split_whitespace().collect(); |
| 81 | + if parts.len() == 2 && parts[1] == filename { |
| 82 | + return Ok(parts[0].to_string()); |
| 83 | + } |
| 84 | + } |
| 85 | + bail!("no checksum found for {filename} in checksums.txt") |
| 86 | +} |
| 87 | + |
| 88 | +/// Extract a binary from a tar.gz or zip archive. Returns the binary bytes. |
| 89 | +fn extract_binary(archive: &[u8], is_zip: bool) -> Result<Vec<u8>> { |
| 90 | + if is_zip { |
| 91 | + // Windows: zip containing icm.exe |
| 92 | + bail!("zip extraction not supported — use the standalone installer on Windows"); |
| 93 | + } |
| 94 | + |
| 95 | + // Unix: tar.gz containing icm |
| 96 | + use flate2::read::GzDecoder; |
| 97 | + let gz = GzDecoder::new(archive); |
| 98 | + let mut tar = tar::Archive::new(gz); |
| 99 | + |
| 100 | + for entry in tar.entries().context("reading tar")? { |
| 101 | + let mut entry = entry.context("tar entry")?; |
| 102 | + let path = entry.path().context("entry path")?; |
| 103 | + if path.file_name().and_then(|n| n.to_str()) == Some(BINARY_NAME) { |
| 104 | + let mut buf = Vec::new(); |
| 105 | + entry.read_to_end(&mut buf).context("reading binary")?; |
| 106 | + return Ok(buf); |
| 107 | + } |
| 108 | + } |
| 109 | + bail!("binary {BINARY_NAME} not found in archive") |
| 110 | +} |
| 111 | + |
| 112 | +/// Run the upgrade flow: fetch latest, verify checksum, replace binary. |
| 113 | +pub fn cmd_upgrade(apply: bool, check_only: bool) -> Result<()> { |
| 114 | + let current_version = env!("CARGO_PKG_VERSION"); |
| 115 | + eprintln!("Current version: {current_version}"); |
| 116 | + |
| 117 | + // 1. Fetch latest release |
| 118 | + eprintln!("Checking for updates..."); |
| 119 | + let latest_tag = fetch_latest_version()?; |
| 120 | + let latest_version = latest_tag.strip_prefix("icm-v").unwrap_or(&latest_tag); |
| 121 | + eprintln!("Latest version: {latest_version}"); |
| 122 | + |
| 123 | + if latest_version == current_version { |
| 124 | + eprintln!("Already up to date."); |
| 125 | + return Ok(()); |
| 126 | + } |
| 127 | + |
| 128 | + if check_only { |
| 129 | + eprintln!("Update available: {current_version} → {latest_version}"); |
| 130 | + eprintln!("Run 'icm upgrade --apply' to install."); |
| 131 | + return Ok(()); |
| 132 | + } |
| 133 | + |
| 134 | + if !apply { |
| 135 | + eprintln!("Update available: {current_version} → {latest_version}"); |
| 136 | + eprintln!("Run 'icm upgrade --apply' to install."); |
| 137 | + return Ok(()); |
| 138 | + } |
| 139 | + |
| 140 | + // 2. Detect target |
| 141 | + let (target, ext) = detect_target()?; |
| 142 | + let archive_name = format!("{BINARY_NAME}-{target}.{ext}"); |
| 143 | + let archive_url = |
| 144 | + format!("https://github.com/{REPO}/releases/download/{latest_tag}/{archive_name}"); |
| 145 | + let checksums_url = |
| 146 | + format!("https://github.com/{REPO}/releases/download/{latest_tag}/checksums.txt"); |
| 147 | + |
| 148 | + // 3. Download archive |
| 149 | + eprintln!("Downloading {archive_name}..."); |
| 150 | + let archive_bytes = download_bytes(&archive_url)?; |
| 151 | + eprintln!(" {} bytes", archive_bytes.len()); |
| 152 | + |
| 153 | + // 4. Download and verify checksum (MANDATORY) |
| 154 | + eprintln!("Verifying integrity..."); |
| 155 | + let checksums = String::from_utf8(download_bytes(&checksums_url)?) |
| 156 | + .context("checksums.txt is not valid UTF-8")?; |
| 157 | + let expected_sha = parse_expected_sha(&checksums, &archive_name)?; |
| 158 | + let actual_sha = sha256_hex(&archive_bytes); |
| 159 | + |
| 160 | + if expected_sha != actual_sha { |
| 161 | + bail!( |
| 162 | + "SHA256 mismatch!\n expected: {expected_sha}\n got: {actual_sha}\nAborting upgrade — binary may be tampered." |
| 163 | + ); |
| 164 | + } |
| 165 | + eprintln!(" SHA256 OK: {actual_sha}"); |
| 166 | + |
| 167 | + // 5. Extract binary |
| 168 | + eprintln!("Extracting..."); |
| 169 | + let is_zip = ext == "zip"; |
| 170 | + let new_binary = extract_binary(&archive_bytes, is_zip)?; |
| 171 | + |
| 172 | + // 6. Replace running binary atomically |
| 173 | + let current_exe = |
| 174 | + std::env::current_exe().context("cannot determine current executable path")?; |
| 175 | + let backup_path: PathBuf = current_exe.with_extension("old"); |
| 176 | + let new_path: PathBuf = current_exe.with_extension("new"); |
| 177 | + |
| 178 | + eprintln!("Installing to {}...", current_exe.display()); |
| 179 | + |
| 180 | + // Write new binary to .new |
| 181 | + { |
| 182 | + let mut f = std::fs::File::create(&new_path) |
| 183 | + .with_context(|| format!("cannot create {}", new_path.display()))?; |
| 184 | + f.write_all(&new_binary) |
| 185 | + .with_context(|| format!("cannot write {}", new_path.display()))?; |
| 186 | + #[cfg(unix)] |
| 187 | + { |
| 188 | + use std::os::unix::fs::PermissionsExt; |
| 189 | + std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755))?; |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + // Atomic swap: rename old → .old, new → current |
| 194 | + if backup_path.exists() { |
| 195 | + std::fs::remove_file(&backup_path).ok(); |
| 196 | + } |
| 197 | + std::fs::rename(¤t_exe, &backup_path) |
| 198 | + .with_context(|| format!("cannot backup {}", current_exe.display()))?; |
| 199 | + if let Err(e) = std::fs::rename(&new_path, ¤t_exe) { |
| 200 | + // Rollback on error |
| 201 | + std::fs::rename(&backup_path, ¤t_exe).ok(); |
| 202 | + return Err(e).context("failed to install new binary (rolled back)"); |
| 203 | + } |
| 204 | + |
| 205 | + // Clean up backup |
| 206 | + std::fs::remove_file(&backup_path).ok(); |
| 207 | + |
| 208 | + eprintln!("Successfully upgraded to {latest_version}"); |
| 209 | + Ok(()) |
| 210 | +} |
0 commit comments