diff --git a/Cargo.lock b/Cargo.lock index ecfe73b..25f0937 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -68,7 +77,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "claude-code-usage-bubble" -version = "0.1.2" +version = "0.1.3" dependencies = [ "dirs", "embed-resource", @@ -76,6 +85,7 @@ dependencies = [ "native-tls", "serde", "serde_json", + "sha2", "simplelog", "thiserror", "tiny-skia", @@ -100,6 +110,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -109,6 +128,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -118,6 +147,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -241,6 +280,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -761,6 +810,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1015,6 +1075,12 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1066,6 +1132,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a86ca95..93c2c0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-usage-bubble" -version = "0.1.2" +version = "0.1.3" edition = "2021" license = "Apache-2.0" description = "Floating bubble showing Claude Code and Codex usage on Windows" @@ -21,6 +21,7 @@ simplelog = "0.12" thiserror = "2" toml = "0.8" tiny-skia = "0.11" +sha2 = "0.10" [dependencies.windows] version = "0.58" diff --git a/src/update/install.rs b/src/update/install.rs index e0d6213..91c0d81 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -9,6 +9,8 @@ use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::{Command, Stdio}; +use sha2::{Digest, Sha256}; + use crate::net::Client; const CREATE_NO_WINDOW: u32 = 0x0800_0000; @@ -18,10 +20,17 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error let current = std::env::current_exe()?; ensure_writable(¤t)?; let staging = stage_path()?; + // Refuse to proceed if either path contains `%`. Inside double quotes + // cmd.exe still expands `%var%` references, so a path containing `%` + // would let cmd substitute environment variables into the swap step. + // Such paths are vanishingly rare on real Windows installs; failing + // fast is safer than rolling a bespoke cmd-escape layer. + reject_unsafe_path(¤t)?; + reject_unsafe_path(&staging)?; if let Some(parent) = staging.parent() { std::fs::create_dir_all(parent)?; } - download(http, &release.asset_url, &staging)?; + download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?; spawn_handoff(&staging, ¤t)?; Ok(()) } @@ -38,7 +47,12 @@ pub fn run_cli(args: &[String]) -> Option { } } -fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super::Error> { +fn download( + http: &Client, + url: &str, + to: &std::path::Path, + expected_sha256: Option<&[u8; 32]>, +) -> Result<(), super::Error> { let resp = http .get(url) .header("User-Agent", super::release::user_agent()) @@ -46,7 +60,39 @@ fn download(http: &Client, url: &str, to: &std::path::Path) -> Result<(), super: if !(200..300).contains(&resp.status()) { return Err(super::Error::Network(crate::net::Error::Status(resp.status()))); } - std::fs::write(to, resp.body())?; + let body = resp.body(); + if let Some(expected) = expected_sha256 { + let mut hasher = Sha256::new(); + hasher.update(body); + let actual = hasher.finalize(); + if actual.as_slice() != expected { + return Err(super::Error::ChecksumMismatch { + expected: hex_encode(expected), + actual: hex_encode(&actual), + }); + } + } + std::fs::write(to, body)?; + Ok(()) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn reject_unsafe_path(p: &std::path::Path) -> Result<(), super::Error> { + let s = p.to_string_lossy(); + if s.contains('%') { + return Err(super::Error::UnsafePath(format!( + "path contains '%' which cmd.exe expands as a variable: {s}" + ))); + } Ok(()) } diff --git a/src/update/mod.rs b/src/update/mod.rs index f0b6955..c27f3f7 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -20,6 +20,10 @@ pub enum Error { NotWritable(String), #[error("malformed version: {0}")] BadVersion(String), + #[error("asset checksum mismatch: expected {expected}, got {actual}")] + ChecksumMismatch { expected: String, actual: String }, + #[error("path rejected for safety: {0}")] + UnsafePath(String), } pub use channel::{current as current_channel, Channel}; diff --git a/src/update/release.rs b/src/update/release.rs index 766e51c..d6ac48b 100644 --- a/src/update/release.rs +++ b/src/update/release.rs @@ -12,6 +12,10 @@ const REPO_NAME: &str = "claude-code-usage-bubble"; pub struct Release { pub version: Version, pub asset_url: String, + /// SHA-256 of the asset bytes, parsed from the GitHub Releases + /// API `digest` field. `None` if GitHub omitted it (older + /// releases predate the digest field). + pub asset_sha256: Option<[u8; 32]>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -71,9 +75,28 @@ pub fn fetch_latest(http: &Client) -> Result Ok(super::CheckOutcome::Available(Release { version: candidate, asset_url: asset.browser_download_url.clone(), + asset_sha256: asset.digest.as_deref().and_then(parse_sha256_digest), })) } +/// Parse a GitHub `digest` field of the form `"sha256:<64 hex chars>"` +/// into a 32-byte array. Returns `None` for any other algorithm or +/// malformed input — callers should treat a missing digest as "no +/// integrity check available" rather than as a parse failure. +fn parse_sha256_digest(raw: &str) -> Option<[u8; 32]> { + let hex = raw.strip_prefix("sha256:")?; + if hex.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, byte_chars) in hex.as_bytes().chunks(2).enumerate() { + let high = (byte_chars[0] as char).to_digit(16)?; + let low = (byte_chars[1] as char).to_digit(16)?; + out[i] = ((high << 4) | low) as u8; + } + Some(out) +} + pub fn user_agent() -> &'static str { concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")) } @@ -88,4 +111,9 @@ struct GhRelease { struct GhAsset { name: String, browser_download_url: String, + /// GitHub started returning `digest: "sha256:..."` on the asset + /// object in 2024. Older releases omit it; we treat that as + /// "verification unavailable" rather than a hard error. + #[serde(default)] + digest: Option, }