Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ directories = "6"
# HTTP (cloud sync)
ureq = "2"
rpassword = "5"
sha2 = "0.10"
flate2 = "1"
tar = "0.4"

# Platform
libc = "0.2"
Expand Down
3 changes: 3 additions & 0 deletions crates/icm-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ tracing-subscriber = { workspace = true }
openssl = { version = "0.10", optional = true }
ureq = { workspace = true }
rpassword = { workspace = true }
sha2 = { workspace = true }
flate2 = { workspace = true }
tar = { workspace = true }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true, optional = true }
axum = { workspace = true, optional = true }
Expand Down
13 changes: 13 additions & 0 deletions crates/icm-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod import;
mod learn_tests;
#[cfg(feature = "tui")]
mod tui;
mod upgrade;
#[cfg(feature = "web")]
mod web;

Expand Down Expand Up @@ -367,6 +368,17 @@ enum Commands {
/// Show current configuration
Config,

/// Upgrade icm to the latest release (with SHA256 verification)
Upgrade {
/// Download and install the new binary (required for actual upgrade)
#[arg(long)]
apply: bool,

/// Only check if an update is available (don't prompt to apply)
#[arg(long)]
check: bool,
},

/// RTK Cloud commands (login, sync, status)
Cloud {
#[command(subcommand)]
Expand Down Expand Up @@ -998,6 +1010,7 @@ fn main() -> Result<()> {
Ok(())
}
Commands::Config => cmd_config(),
Commands::Upgrade { apply, check } => upgrade::cmd_upgrade(apply, check),
Commands::Bench { count } => cmd_bench(count),
Commands::BenchRecall {
model,
Expand Down
210 changes: 210 additions & 0 deletions crates/icm-cli/src/upgrade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! Self-upgrade command with SHA256 integrity verification.
//!
//! Downloads the latest release binary from GitHub, verifies its SHA256
//! against the release's `checksums.txt`, and replaces the running binary.

use std::io::{Read, Write};
use std::path::PathBuf;

use anyhow::{anyhow, bail, Context, Result};
use sha2::{Digest, Sha256};

const REPO: &str = "rtk-ai/icm";
const BINARY_NAME: &str = "icm";

/// Detect the target triple for this platform.
fn detect_target() -> Result<(&'static str, &'static str)> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;

let target_suffix = match os {
"macos" => "apple-darwin",
"linux" => "unknown-linux-gnu",
"windows" => "pc-windows-msvc",
_ => bail!("Unsupported OS: {os}"),
};

let arch = match arch {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
_ => bail!("Unsupported architecture: {arch}"),
};

let ext = if os == "windows" { "zip" } else { "tar.gz" };
let target = Box::leak(format!("{arch}-{target_suffix}").into_boxed_str());
Ok((target, ext))
}

/// Fetch the latest release tag from the GitHub API.
fn fetch_latest_version() -> Result<String> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", "icm-upgrader")
.set("Accept", "application/vnd.github+json")
.call()
.context("failed to fetch latest release")?;

let json: serde_json::Value = resp.into_json().context("invalid API response")?;
let tag = json
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing tag_name in API response"))?;
Ok(tag.to_string())
}

/// Download a URL to a byte vector with size tracking.
fn download_bytes(url: &str) -> Result<Vec<u8>> {
let resp = ureq::get(url)
.set("User-Agent", "icm-upgrader")
.call()
.with_context(|| format!("failed to download {url}"))?;

let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.context("failed to read response body")?;
Ok(buf)
}

/// Compute SHA256 of bytes as lowercase hex.
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}

/// Parse the expected SHA256 for a file from a `sha256sum` output.
/// Format per line: `<64-hex> <filename>`.
fn parse_expected_sha(checksums: &str, filename: &str) -> Result<String> {
for line in checksums.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() == 2 && parts[1] == filename {
return Ok(parts[0].to_string());
}
}
bail!("no checksum found for {filename} in checksums.txt")
}

/// Extract a binary from a tar.gz or zip archive. Returns the binary bytes.
fn extract_binary(archive: &[u8], is_zip: bool) -> Result<Vec<u8>> {
if is_zip {
// Windows: zip containing icm.exe
bail!("zip extraction not supported — use the standalone installer on Windows");
}

// Unix: tar.gz containing icm
use flate2::read::GzDecoder;
let gz = GzDecoder::new(archive);
let mut tar = tar::Archive::new(gz);

for entry in tar.entries().context("reading tar")? {
let mut entry = entry.context("tar entry")?;
let path = entry.path().context("entry path")?;
if path.file_name().and_then(|n| n.to_str()) == Some(BINARY_NAME) {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).context("reading binary")?;
return Ok(buf);
}
}
bail!("binary {BINARY_NAME} not found in archive")
}

/// Run the upgrade flow: fetch latest, verify checksum, replace binary.
pub fn cmd_upgrade(apply: bool, check_only: bool) -> Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
eprintln!("Current version: {current_version}");

// 1. Fetch latest release
eprintln!("Checking for updates...");
let latest_tag = fetch_latest_version()?;
let latest_version = latest_tag.strip_prefix("icm-v").unwrap_or(&latest_tag);
eprintln!("Latest version: {latest_version}");

if latest_version == current_version {
eprintln!("Already up to date.");
return Ok(());
}

if check_only {
eprintln!("Update available: {current_version} → {latest_version}");
eprintln!("Run 'icm upgrade --apply' to install.");
return Ok(());
}

if !apply {
eprintln!("Update available: {current_version} → {latest_version}");
eprintln!("Run 'icm upgrade --apply' to install.");
return Ok(());
}

// 2. Detect target
let (target, ext) = detect_target()?;
let archive_name = format!("{BINARY_NAME}-{target}.{ext}");
let archive_url =
format!("https://github.com/{REPO}/releases/download/{latest_tag}/{archive_name}");
let checksums_url =
format!("https://github.com/{REPO}/releases/download/{latest_tag}/checksums.txt");

// 3. Download archive
eprintln!("Downloading {archive_name}...");
let archive_bytes = download_bytes(&archive_url)?;
eprintln!(" {} bytes", archive_bytes.len());

// 4. Download and verify checksum (MANDATORY)
eprintln!("Verifying integrity...");
let checksums = String::from_utf8(download_bytes(&checksums_url)?)
.context("checksums.txt is not valid UTF-8")?;
let expected_sha = parse_expected_sha(&checksums, &archive_name)?;
let actual_sha = sha256_hex(&archive_bytes);

if expected_sha != actual_sha {
bail!(
"SHA256 mismatch!\n expected: {expected_sha}\n got: {actual_sha}\nAborting upgrade — binary may be tampered."
);
}
eprintln!(" SHA256 OK: {actual_sha}");

// 5. Extract binary
eprintln!("Extracting...");
let is_zip = ext == "zip";
let new_binary = extract_binary(&archive_bytes, is_zip)?;

// 6. Replace running binary atomically
let current_exe =
std::env::current_exe().context("cannot determine current executable path")?;
let backup_path: PathBuf = current_exe.with_extension("old");
let new_path: PathBuf = current_exe.with_extension("new");

eprintln!("Installing to {}...", current_exe.display());

// Write new binary to .new
{
let mut f = std::fs::File::create(&new_path)
.with_context(|| format!("cannot create {}", new_path.display()))?;
f.write_all(&new_binary)
.with_context(|| format!("cannot write {}", new_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&new_path, std::fs::Permissions::from_mode(0o755))?;
}
}

// Atomic swap: rename old → .old, new → current
if backup_path.exists() {
std::fs::remove_file(&backup_path).ok();
}
std::fs::rename(&current_exe, &backup_path)
.with_context(|| format!("cannot backup {}", current_exe.display()))?;
if let Err(e) = std::fs::rename(&new_path, &current_exe) {
// Rollback on error
std::fs::rename(&backup_path, &current_exe).ok();
return Err(e).context("failed to install new binary (rolled back)");
}

// Clean up backup
std::fs::remove_file(&backup_path).ok();

eprintln!("Successfully upgraded to {latest_version}");
Ok(())
}
28 changes: 28 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ install() {
error "Failed to download binary"
fi

# SHA256 checksum verification (mandatory for security)
CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
CHECKSUMS_FILE="${TEMP_DIR}/checksums.txt"
info "Downloading checksums: ${CHECKSUMS_URL}"
if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS_FILE"; then
error "Failed to download checksums.txt (required for integrity verification)"
fi

ARCHIVE_NAME="${BINARY_NAME}-${TARGET}.${EXT}"
EXPECTED_SHA=$(grep " ${ARCHIVE_NAME}$" "$CHECKSUMS_FILE" | awk '{print $1}')
if [ -z "$EXPECTED_SHA" ]; then
error "No checksum found for ${ARCHIVE_NAME} in checksums.txt"
fi

# Compute actual SHA256 (shasum on macOS, sha256sum on Linux)
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL_SHA=$(sha256sum "$ARCHIVE" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
ACTUAL_SHA=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')
else
error "Neither sha256sum nor shasum found — cannot verify integrity"
fi

if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then
error "SHA256 mismatch! Expected: ${EXPECTED_SHA}, got: ${ACTUAL_SHA}"
fi
info "SHA256 verified: ${ACTUAL_SHA}"

info "Extracting..."
if [ "$OS" = "windows" ]; then
unzip -o "$ARCHIVE" -d "$TEMP_DIR"
Expand Down
Loading