Skip to content

Commit 36a7283

Browse files
committed
feat(security): add SHA256 checksum verification + icm upgrade --apply
- install.sh now MANDATORY verifies SHA256 against checksums.txt before installing - New 'icm upgrade --check' / 'icm upgrade --apply' command: - Fetches latest release from GitHub - Downloads binary + checksums.txt - Verifies SHA256 before replacing running binary - Atomic replacement with rollback on failure - Refuses to upgrade if SHA256 mismatch (tamper detection) Security: no binary is ever installed without SHA256 verification.
1 parent 392f836 commit 36a7283

File tree

6 files changed

+260
-0
lines changed

6 files changed

+260
-0
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ directories = "6"
4646
# HTTP (cloud sync)
4747
ureq = "2"
4848
rpassword = "5"
49+
sha2 = "0.10"
50+
flate2 = "1"
51+
tar = "0.4"
4952

5053
# Platform
5154
libc = "0.2"

crates/icm-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ tracing-subscriber = { workspace = true }
3535
openssl = { version = "0.10", optional = true }
3636
ureq = { workspace = true }
3737
rpassword = { workspace = true }
38+
sha2 = { workspace = true }
39+
flate2 = { workspace = true }
40+
tar = { workspace = true }
3841
ratatui = { workspace = true, optional = true }
3942
crossterm = { workspace = true, optional = true }
4043
axum = { workspace = true, optional = true }

crates/icm-cli/src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod import;
88
mod learn_tests;
99
#[cfg(feature = "tui")]
1010
mod tui;
11+
mod upgrade;
1112
#[cfg(feature = "web")]
1213
mod web;
1314

@@ -367,6 +368,17 @@ enum Commands {
367368
/// Show current configuration
368369
Config,
369370

371+
/// Upgrade icm to the latest release (with SHA256 verification)
372+
Upgrade {
373+
/// Download and install the new binary (required for actual upgrade)
374+
#[arg(long)]
375+
apply: bool,
376+
377+
/// Only check if an update is available (don't prompt to apply)
378+
#[arg(long)]
379+
check: bool,
380+
},
381+
370382
/// RTK Cloud commands (login, sync, status)
371383
Cloud {
372384
#[command(subcommand)]
@@ -998,6 +1010,7 @@ fn main() -> Result<()> {
9981010
Ok(())
9991011
}
10001012
Commands::Config => cmd_config(),
1013+
Commands::Upgrade { apply, check } => upgrade::cmd_upgrade(apply, check),
10011014
Commands::Bench { count } => cmd_bench(count),
10021015
Commands::BenchRecall {
10031016
model,

crates/icm-cli/src/upgrade.rs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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(&current_exe, &backup_path)
198+
.with_context(|| format!("cannot backup {}", current_exe.display()))?;
199+
if let Err(e) = std::fs::rename(&new_path, &current_exe) {
200+
// Rollback on error
201+
std::fs::rename(&backup_path, &current_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+
}

install.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,34 @@ install() {
6464
error "Failed to download binary"
6565
fi
6666

67+
# SHA256 checksum verification (mandatory for security)
68+
CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
69+
CHECKSUMS_FILE="${TEMP_DIR}/checksums.txt"
70+
info "Downloading checksums: ${CHECKSUMS_URL}"
71+
if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS_FILE"; then
72+
error "Failed to download checksums.txt (required for integrity verification)"
73+
fi
74+
75+
ARCHIVE_NAME="${BINARY_NAME}-${TARGET}.${EXT}"
76+
EXPECTED_SHA=$(grep " ${ARCHIVE_NAME}$" "$CHECKSUMS_FILE" | awk '{print $1}')
77+
if [ -z "$EXPECTED_SHA" ]; then
78+
error "No checksum found for ${ARCHIVE_NAME} in checksums.txt"
79+
fi
80+
81+
# Compute actual SHA256 (shasum on macOS, sha256sum on Linux)
82+
if command -v sha256sum >/dev/null 2>&1; then
83+
ACTUAL_SHA=$(sha256sum "$ARCHIVE" | awk '{print $1}')
84+
elif command -v shasum >/dev/null 2>&1; then
85+
ACTUAL_SHA=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')
86+
else
87+
error "Neither sha256sum nor shasum found — cannot verify integrity"
88+
fi
89+
90+
if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then
91+
error "SHA256 mismatch! Expected: ${EXPECTED_SHA}, got: ${ACTUAL_SHA}"
92+
fi
93+
info "SHA256 verified: ${ACTUAL_SHA}"
94+
6795
info "Extracting..."
6896
if [ "$OS" = "windows" ]; then
6997
unzip -o "$ARCHIVE" -d "$TEMP_DIR"

0 commit comments

Comments
 (0)