From 0f3acd40d4c3dcfdf0d1717e4b3bce3424505236 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 16 May 2026 13:37:37 +0700 Subject: [PATCH] feat(update): SHA-256 verification + reject paths containing '%' GitHub's Releases API exposes a `digest: "sha256:..."` field on every asset since 2024. We now parse it, hash the downloaded bytes locally, and abort with ChecksumMismatch if they disagree. Releases that predate the field (none currently exist for this repo) skip verification rather than fail, so v0.1.0 / v0.1.1 / v0.1.2 still update normally. cmd.exe expands `%var%` even inside double-quoted arguments, which would let a path like `C:\Users\%PATHEXT%\bubble.exe` substitute the expansion. Real Windows paths with `%` are vanishingly rare, so we fail fast with UnsafePath rather than ship a bespoke cmd-escape implementation. Bumps version to 0.1.3. --- Cargo.lock | 74 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- src/update/install.rs | 52 ++++++++++++++++++++++++++++-- src/update/mod.rs | 4 +++ src/update/release.rs | 28 ++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) 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, }