Compare commits

...
2 Commits
Author SHA1 Message Date
tiennm99 0f3acd40d4 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.
2026-05-16 13:37:37 +07:00
tiennm99 a132c02711 fix(update): bypass Rust arg escaping in cmd.exe handoff
Rust's std::process::Command escapes inner double quotes as \" when
wrapping the args(["/c", &cmd]) array. cmd.exe does not understand the
\" escape, so the swap-and-restart command got mangled: `start ""
"PATH"` arrived as `start \"\" \"PATH\"`, which the cmd parser
collapsed into `start \ PATH` — producing the "Windows cannot find
'\'" dialog and aborting the update.

Switching to raw_arg lets us hand cmd.exe the literal command line it
expects. The two quote characters cmd needs to keep are the outer pair
wrapping the whole /c argument; cmd's "more than two quotes, special
chars present" branch then preserves the inner path quotes intact.

Bumps version to 0.1.2 since this is the first updater fix that ships
through the updater itself for any future v0.1.2+ user.
2026-05-16 13:04:08 +07:00
5 changed files with 163 additions and 6 deletions
Generated
+73 -1
View File
@@ -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.1"
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"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-bubble"
version = "0.1.1"
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"
+56 -4
View File
@@ -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(&current)?;
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(&current)?;
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, &current)?;
Ok(())
}
@@ -38,7 +47,12 @@ pub fn run_cli(args: &[String]) -> Option<i32> {
}
}
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(())
}
@@ -58,8 +104,14 @@ fn spawn_handoff(source: &std::path::Path, target: &std::path::Path) -> Result<(
let cmd = format!(
r#"timeout /t 2 /nobreak >nul & move /y "{src_str}" "{tgt_str}" & start "" "{tgt_str}""#
);
// raw_arg bypasses Rust's std auto-escaping which would turn the inner
// `"` characters into `\"`. cmd.exe does not recognise `\"`, so the
// escaped form makes `start` see the path as `\\` and emit a
// "Windows cannot find '\\'" dialog. Feeding the command line raw
// preserves the quotes cmd.exe actually expects.
Command::new("cmd.exe")
.args(["/c", &cmd])
.raw_arg("/c")
.raw_arg(format!("\"{cmd}\""))
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
+4
View File
@@ -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};
+28
View File
@@ -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<super::CheckOutcome, super::Error>
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<String>,
}