From 9e2666ff0e7fdd59936ad7a8561ce77e8a1c31fe Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 21 Oct 2026 12:41:08 +0700 Subject: [PATCH] feat: port the data layer to ureq with the Windows system proxy Replace the WinHTTP client with a ureq agent that keeps the same Client/Response surface: status errors come back as responses, gzip, 60 s connect / 30 s receive / 120 s total timeouts, rustls with the platform verifier and a per-request User-Agent override. Environment proxies come from ureq. On Windows, when none is set, the static Internet Settings proxy is read from HKCU and parsed by a pure core module (per-scheme ProxyServer, ProxyOverride globs and ); bypassed hosts go through a direct agent. PAC/WPAD is not evaluated. The usage providers and credential sources compile again on every platform. Refresh keeps the Windows candidates and CREATE_NO_WINDOW; Unix spawns claude/codex from PATH plus the usual per-user install directories and reaps the child on a detached thread. An empty CODEX_HOME now counts as unset and a float expiresAt is truncated instead of ignored. Fixture tests cover the OAuth usage body, rate-limit headers (including a 429 over loopback), the ChatGPT envelope, ISO 8601 edge cases, credential JSON and proxy strings. An ignored live_poll test polls the real providers. --- Cargo.lock | 145 ++++- Cargo.toml | 8 +- src/core/mod.rs | 1 + src/core/proxy.rs | 221 ++++++++ src/creds/codex_auth.rs | 97 +++- src/creds/local_fs.rs | 49 +- src/lib.rs | 3 + src/net/mod.rs | 497 +++++++++++++++++- src/net/winhttp.rs | 431 --------------- src/platform/mod.rs | 5 + src/platform/windows.rs | 18 + src/usage/anthropic.rs | 150 +++++- src/usage/chatgpt.rs | 49 ++ src/usage/headers.rs | 92 ++++ src/usage/mod.rs | 34 +- src/usage/refresh.rs | 254 +++++++-- src/usage/registry.rs | 48 +- .../anthropic-usage-five-hour-only.json | 3 + tests/fixtures/anthropic-usage-full.json | 6 + .../anthropic-usage-null-buckets.json | 4 + .../anthropic-usage-null-utilization.json | 4 + tests/fixtures/anthropic-usage-partial.json | 4 + tests/fixtures/chatgpt-usage-full.json | 20 + .../chatgpt-usage-negative-reset.json | 6 + .../chatgpt-usage-null-rate-limit.json | 4 + .../fixtures/chatgpt-usage-null-windows.json | 7 + .../claude-credentials-float-expiry.json | 3 + tests/fixtures/claude-credentials-full.json | 9 + .../claude-credentials-missing-oauth.json | 3 + .../claude-credentials-missing-token.json | 3 + .../codex-auth-empty-access-token.json | 3 + tests/fixtures/codex-auth-full.json | 10 + .../codex-auth-missing-access-token.json | 3 + tests/fixtures/codex-auth-null-tokens.json | 4 + 34 files changed, 1686 insertions(+), 512 deletions(-) create mode 100644 src/core/proxy.rs delete mode 100644 src/net/winhttp.rs create mode 100644 src/platform/mod.rs create mode 100644 src/platform/windows.rs create mode 100644 tests/fixtures/anthropic-usage-five-hour-only.json create mode 100644 tests/fixtures/anthropic-usage-full.json create mode 100644 tests/fixtures/anthropic-usage-null-buckets.json create mode 100644 tests/fixtures/anthropic-usage-null-utilization.json create mode 100644 tests/fixtures/anthropic-usage-partial.json create mode 100644 tests/fixtures/chatgpt-usage-full.json create mode 100644 tests/fixtures/chatgpt-usage-negative-reset.json create mode 100644 tests/fixtures/chatgpt-usage-null-rate-limit.json create mode 100644 tests/fixtures/chatgpt-usage-null-windows.json create mode 100644 tests/fixtures/claude-credentials-float-expiry.json create mode 100644 tests/fixtures/claude-credentials-full.json create mode 100644 tests/fixtures/claude-credentials-missing-oauth.json create mode 100644 tests/fixtures/claude-credentials-missing-token.json create mode 100644 tests/fixtures/codex-auth-empty-access-token.json create mode 100644 tests/fixtures/codex-auth-full.json create mode 100644 tests/fixtures/codex-auth-missing-access-token.json create mode 100644 tests/fixtures/codex-auth-null-tokens.json diff --git a/Cargo.lock b/Cargo.lock index 1fabfc8..60fd5db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,6 +509,7 @@ name = "claude-code-usage-bubble" version = "0.5.0" dependencies = [ "dirs 6.0.0", + "flate2", "libc", "libloading 0.9.0", "log", @@ -526,6 +527,7 @@ dependencies = [ "ureq", "webview2-com", "windows", + "windows-registry", "x11rb", ] @@ -1891,6 +1893,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2462,6 +2494,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2975,6 +3013,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2984,6 +3034,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.16" @@ -3010,6 +3087,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3067,6 +3153,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -3299,6 +3408,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplelog" version = "0.12.2" @@ -3546,7 +3671,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3602,7 +3727,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3756,7 +3881,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3779,7 +3904,7 @@ checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4271,6 +4396,7 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", "webpki-roots", @@ -4548,6 +4674,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.10" @@ -4974,7 +5109,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", diff --git a/Cargo.toml b/Cargo.toml index d597c3c..2f4806e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,8 @@ path = "src/main.rs" tauri = { version = "2", features = ["tray-icon", "macos-private-api"] } tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" -ureq = "3" +# rustls with the OS verifier keeps corporate roots trusted; gzip is a default. +ureq = { version = "3", features = ["platform-verifier"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" @@ -51,6 +52,11 @@ windows = { version = "0.62", features = [ ] } # Same major as tauri's; used for the WebView2 runtime check. webview2-com = "0.39" +# Same major as the copy auto-launch pulls; reads the system proxy. +windows-registry = "0.6" + +[dev-dependencies] +flate2 = "1" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src/core/mod.rs b/src/core/mod.rs index 842360a..7b716f9 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,6 +5,7 @@ pub mod geom; pub mod i18n; pub mod menu_model; pub mod payload; +pub mod proxy; pub mod settings; pub mod state; pub mod usage_color; diff --git a/src/core/proxy.rs b/src/core/proxy.rs new file mode 100644 index 0000000..dfc952a --- /dev/null +++ b/src/core/proxy.rs @@ -0,0 +1,221 @@ +//! Parsing of the Windows "Internet Settings" static proxy. +//! +//! The registry reader lives in `platform::windows`; everything here is pure +//! so it can be tested on any host. PAC/WPAD scripts are not evaluated. + +/// A static system proxy and the hosts that bypass it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SystemProxy { + /// Proxy URI such as `http://proxy.corp:8080`, ready for `ureq::Proxy::new`. + pub url: String, + /// `ProxyOverride` entries, trimmed and non-empty. + pub bypass: Vec, +} + +impl SystemProxy { + /// Whether a request to `host` must skip the proxy. + pub fn bypasses(&self, host: &str) -> bool { + bypass_matches(&self.bypass, host) + } +} + +/// Build the proxy from the raw `ProxyEnable`, `ProxyServer` and +/// `ProxyOverride` registry values. Returns `None` unless the proxy is +/// enabled and the server string names a usable entry. +pub fn from_registry_values( + enable: Option, + server: Option<&str>, + proxy_override: Option<&str>, +) -> Option { + if enable != Some(1) { + return None; + } + let url = parse_proxy_server(server?)?; + Some(SystemProxy { + url, + bypass: parse_override(proxy_override.unwrap_or_default()), + }) +} + +/// Pick the proxy for HTTPS traffic out of a `ProxyServer` value. +/// +/// A per-scheme value (`http=h:p;https=h:p;…`) uses the `https=` entry, else +/// the `http=` entry. A bare `h:p` applies to every scheme. The result always +/// carries a scheme, defaulting to `http://` (a CONNECT proxy). +pub fn parse_proxy_server(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let target = if value.contains('=') { + let entries: Vec<(String, &str)> = value + .split(';') + .filter_map(|entry| entry.split_once('=')) + .map(|(scheme, addr)| (scheme.trim().to_ascii_lowercase(), addr.trim())) + .filter(|(_, addr)| !addr.is_empty()) + .collect(); + let pick = |scheme: &str| { + entries + .iter() + .find(|(s, _)| s == scheme) + .map(|(_, addr)| *addr) + }; + pick("https").or_else(|| pick("http"))? + } else { + value + }; + if target.contains("://") { + Some(target.to_string()) + } else { + Some(format!("http://{target}")) + } +} + +/// Split a `ProxyOverride` value on `;` into trimmed, non-empty entries. +pub fn parse_override(value: &str) -> Vec { + value + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + +/// Whether `host` matches any bypass entry. Entries are case-insensitive +/// globs where `*` matches any run of characters; `` matches every +/// host without a dot. +pub fn bypass_matches(entries: &[String], host: &str) -> bool { + let host = host.to_ascii_lowercase(); + entries.iter().any(|entry| { + if entry.eq_ignore_ascii_case("") { + !host.contains('.') + } else { + glob_match(&entry.to_ascii_lowercase(), &host) + } + }) +} + +/// Iterative `*`-only glob match with single-star backtracking. +fn glob_match(pattern: &str, text: &str) -> bool { + let p = pattern.as_bytes(); + let t = text.as_bytes(); + let (mut pi, mut ti) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while ti < t.len() { + if pi < p.len() && p[pi] == b'*' { + star = Some((pi, ti)); + pi += 1; + } else if pi < p.len() && p[pi] == t[ti] { + pi += 1; + ti += 1; + } else if let Some((sp, st)) = star { + pi = sp + 1; + ti = st + 1; + star = Some((sp, st + 1)); + } else { + return false; + } + } + p[pi..].iter().all(|&c| c == b'*') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entries(value: &str) -> Vec { + parse_override(value) + } + + #[test] + fn per_scheme_server_prefers_https_entry() { + assert_eq!( + parse_proxy_server("http=web:80;https=secure:8443;ftp=f:21").as_deref(), + Some("http://secure:8443") + ); + } + + #[test] + fn per_scheme_server_without_https_uses_http_entry() { + assert_eq!( + parse_proxy_server("ftp=f:21; HTTP=web:3128").as_deref(), + Some("http://web:3128") + ); + } + + #[test] + fn per_scheme_server_without_http_entries_is_none() { + assert_eq!(parse_proxy_server("socks=s:1080"), None); + assert_eq!(parse_proxy_server("https=;http="), None); + } + + #[test] + fn bare_server_applies_to_all_schemes() { + assert_eq!( + parse_proxy_server(" proxy.corp:8080 ").as_deref(), + Some("http://proxy.corp:8080") + ); + assert_eq!( + parse_proxy_server("http://proxy.corp:8080").as_deref(), + Some("http://proxy.corp:8080") + ); + assert_eq!(parse_proxy_server(" "), None); + } + + #[test] + fn registry_values_need_enable_flag_and_server() { + assert_eq!( + from_registry_values(Some(0), Some("p:1"), Some("")), + None + ); + assert_eq!(from_registry_values(None, Some("p:1"), None), None); + assert_eq!(from_registry_values(Some(1), None, None), None); + assert_eq!( + from_registry_values(Some(1), Some("p:1"), Some("*.corp; ;")), + Some(SystemProxy { + url: "http://p:1".into(), + bypass: vec!["*.corp".into(), "".into()], + }) + ); + assert_eq!( + from_registry_values(Some(1), Some("p:1"), None).map(|p| p.bypass), + Some(Vec::new()) + ); + } + + #[test] + fn override_wildcard_matches_subdomains_case_insensitively() { + let list = entries("*.anthropic.com"); + assert!(bypass_matches(&list, "api.anthropic.com")); + assert!(bypass_matches(&list, "API.Anthropic.COM")); + assert!(!bypass_matches(&list, "anthropic.com")); + assert!(!bypass_matches(&list, "chatgpt.com")); + } + + #[test] + fn override_exact_host_matches_only_that_host() { + let list = entries("chatgpt.com"); + assert!(bypass_matches(&list, "chatgpt.com")); + assert!(!bypass_matches(&list, "api.chatgpt.com")); + assert!(!bypass_matches(&list, "chatgpt.com.evil")); + } + + #[test] + fn override_local_matches_dotless_hosts() { + let list = entries(""); + assert!(bypass_matches(&list, "intranet")); + assert!(bypass_matches(&list, "localhost")); + assert!(!bypass_matches(&list, "api.anthropic.com")); + assert!(!bypass_matches(&list, "127.0.0.1")); + } + + #[test] + fn override_globs_cover_prefixes_and_inner_stars() { + let list = entries("10.*;api.*.com"); + assert!(bypass_matches(&list, "10.1.2.3")); + assert!(bypass_matches(&list, "api.anthropic.com")); + assert!(!bypass_matches(&list, "chatgpt.com")); + assert!(bypass_matches(&entries("*"), "anything.example")); + assert!(!bypass_matches(&[], "chatgpt.com")); + } +} diff --git a/src/creds/codex_auth.rs b/src/creds/codex_auth.rs index 41d9d87..2110a04 100644 --- a/src/creds/codex_auth.rs +++ b/src/creds/codex_auth.rs @@ -5,6 +5,7 @@ // timestamp in the file; we discover expiry only when the server returns // 401 or 403 to a poll. +use std::ffi::OsString; use std::path::PathBuf; use std::time::UNIX_EPOCH; @@ -17,16 +18,36 @@ pub struct LocalCodexCreds { impl LocalCodexCreds { pub fn detect() -> Option { - let path = if let Some(home) = std::env::var_os("CODEX_HOME") { - PathBuf::from(home).join("auth.json") - } else { - dirs::home_dir()?.join(".codex").join("auth.json") - }; + let path = auth_path(std::env::var_os("CODEX_HOME"), dirs::home_dir())?; let id = format!("codex:{}", path.display()); Some(Self { path, id }) } } +/// `$CODEX_HOME/auth.json`, else `~/.codex/auth.json`. An empty `CODEX_HOME` +/// counts as unset (as in the Codex CLI), so it never resolves to a path +/// relative to the working directory. +fn auth_path(codex_home: Option, home: Option) -> Option { + match codex_home.filter(|h| !h.is_empty()) { + Some(dir) => Some(PathBuf::from(dir).join("auth.json")), + None => Some(home?.join(".codex").join("auth.json")), + } +} + +/// Parse the `auth.json` shape written by the Codex CLI. +fn parse_codex_json(content: &str) -> Result { + let parsed: Envelope = serde_json::from_str(content)?; + let tokens = parsed.tokens.ok_or(super::Error::MissingField("tokens"))?; + if tokens.access_token.is_empty() { + return Err(super::Error::MissingField("access_token")); + } + Ok(super::Token { + access_token: tokens.access_token, + expires_at_unix_ms: None, + account_id: tokens.account_id, + }) +} + impl super::CredentialSource for LocalCodexCreds { fn id(&self) -> &str { &self.id @@ -34,18 +55,7 @@ impl super::CredentialSource for LocalCodexCreds { fn read(&self) -> Result { let content = std::fs::read_to_string(&self.path)?; - let parsed: Envelope = serde_json::from_str(&content)?; - let tokens = parsed - .tokens - .ok_or(super::Error::MissingField("tokens"))?; - if tokens.access_token.is_empty() { - return Err(super::Error::MissingField("access_token")); - } - Ok(super::Token { - access_token: tokens.access_token, - expires_at_unix_ms: None, - account_id: tokens.account_id, - }) + parse_codex_json(&content) } fn signature(&self) -> Option { @@ -74,3 +84,56 @@ struct Tokens { access_token: String, account_id: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::Error; + + fn fixture(name: &str) -> String { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")) + } + + #[test] + fn full_auth_reads_token_and_account() { + let token = parse_codex_json(&fixture("codex-auth-full.json")).expect("token"); + assert_eq!(token.access_token, "fixture-access-token"); + assert_eq!(token.account_id.as_deref(), Some("fixture-account")); + assert_eq!(token.expires_at_unix_ms, None); + } + + #[test] + fn null_tokens_is_a_missing_field() { + let err = parse_codex_json(&fixture("codex-auth-null-tokens.json")).unwrap_err(); + assert!(matches!(err, Error::MissingField("tokens")), "{err}"); + let err = parse_codex_json("{}").unwrap_err(); + assert!(matches!(err, Error::MissingField("tokens")), "{err}"); + } + + #[test] + fn empty_or_missing_access_token_is_rejected() { + let err = parse_codex_json(&fixture("codex-auth-empty-access-token.json")).unwrap_err(); + assert!(matches!(err, Error::MissingField("access_token")), "{err}"); + let err = parse_codex_json(&fixture("codex-auth-missing-access-token.json")).unwrap_err(); + assert!(matches!(err, Error::Json(_)), "{err}"); + } + + #[test] + fn codex_home_wins_unless_empty() { + let home = Some(PathBuf::from("/home/u")); + assert_eq!( + auth_path(Some(OsString::from("/opt/codex")), home.clone()), + Some(PathBuf::from("/opt/codex/auth.json")) + ); + assert_eq!( + auth_path(Some(OsString::new()), home.clone()), + Some(PathBuf::from("/home/u/.codex/auth.json")) + ); + assert_eq!( + auth_path(None, home), + Some(PathBuf::from("/home/u/.codex/auth.json")) + ); + assert_eq!(auth_path(Some(OsString::new()), None), None); + } +} diff --git a/src/creds/local_fs.rs b/src/creds/local_fs.rs index 921ea6f..3d5f081 100644 --- a/src/creds/local_fs.rs +++ b/src/creds/local_fs.rs @@ -62,10 +62,57 @@ pub(crate) fn parse_claude_json(content: &str) -> Result String { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")) + } + + #[test] + fn full_credentials_read_token_and_expiry() { + let token = parse_claude_json(&fixture("claude-credentials-full.json")).expect("token"); + assert_eq!(token.access_token, "sk-ant-oat01-fixture"); + assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000)); + assert_eq!(token.account_id, None); + } + + #[test] + fn float_expiry_is_truncated_to_milliseconds() { + let token = + parse_claude_json(&fixture("claude-credentials-float-expiry.json")).expect("token"); + assert_eq!(token.expires_at_unix_ms, Some(1_761_040_800_000)); + } + + #[test] + fn missing_expiry_is_none() { + let token = parse_claude_json(r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":null}}"#) + .expect("token"); + assert_eq!(token.expires_at_unix_ms, None); + } + + #[test] + fn missing_fields_are_reported() { + let err = parse_claude_json(&fixture("claude-credentials-missing-token.json")).unwrap_err(); + assert!(matches!(err, Error::MissingField("accessToken")), "{err}"); + let err = parse_claude_json(&fixture("claude-credentials-missing-oauth.json")).unwrap_err(); + assert!(matches!(err, Error::MissingField("claudeAiOauth")), "{err}"); + let err = parse_claude_json("not json").unwrap_err(); + assert!(matches!(err, Error::Json(_)), "{err}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 16fab6f..b13ea9a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,10 @@ //! that items wired up incrementally do not trip `dead_code`. pub mod core; +pub mod creds; pub mod diag; +pub mod net; +pub mod platform; pub mod usage; use tauri::{RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent}; diff --git a/src/net/mod.rs b/src/net/mod.rs index a20b8a9..754eb79 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -1,9 +1,492 @@ -// `net` namespace: HTTP client implementations. -// -// `winhttp` is the only backend right now (Windows-only app). It produces -// `Response` values that are cheap to inspect via `status`, `header`, -// `text`, and `json`. +//! Minimal blocking HTTP client on `ureq`. +//! +//! One [`Client`] is shared across poll workers. 4xx and 5xx responses come +//! back as ordinary [`Response`] values so providers can read the status and +//! the rate-limit headers of an error reply. Tokens and request headers are +//! never logged here. -pub mod winhttp; +use std::time::Duration; -pub use winhttp::{Client, Error, Response}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use ureq::http::{self, Uri}; +use ureq::tls::{RootCerts, TlsConfig}; +use ureq::{Agent, Proxy}; + +use crate::core::proxy::SystemProxy; + +/// Default User-Agent; a per-request `User-Agent` header replaces it. +pub const USER_AGENT: &str = concat!("claude-code-usage-bubble/", env!("CARGO_PKG_VERSION")); + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(60); +const RECEIVE_TIMEOUT: Duration = Duration::from_secs(30); +const GLOBAL_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("HTTP transport: {0}")] + Transport(String), + #[error("response was not valid UTF-8")] + Utf8, + #[error("JSON parse: {0}")] + Json(#[from] serde_json::Error), + /// Kept for the providers' 401/403 match arms. The client reports every + /// status as a `Response`, so it never builds this variant itself. + #[error("response status {0}")] + Status(u32), +} + +impl From for Error { + fn from(err: ureq::Error) -> Self { + Error::Transport(err.to_string()) + } +} + +impl From for Error { + fn from(err: http::Error) -> Self { + Error::Transport(err.to_string()) + } +} + +/// The static system proxy that applies when no environment proxy is set. +/// +/// Environment proxies (`HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, …) are read by +/// `ureq` itself and win over the system proxy. On Windows the fallback is the +/// `Internet Settings` proxy; other platforms have none. +pub fn system_proxy() -> Option { + if Proxy::try_from_env().is_some() { + return None; + } + #[cfg(windows)] + { + crate::platform::windows::system_proxy() + } + #[cfg(not(windows))] + { + None + } +} + +/// A system-proxied agent plus the bypass rules that route around it. +struct Proxied { + agent: Agent, + proxy: SystemProxy, +} + +pub struct Client { + /// Direct agent, or the environment-proxied one when an env proxy is set. + direct: Agent, + proxied: Option, +} + +impl Client { + /// Create a client whose default User-Agent is `user_agent`. + pub fn new(user_agent: &str) -> Result { + Self::build(user_agent, Proxy::try_from_env(), system_proxy()) + } + + /// A client that ignores every proxy, for loopback tests. + #[cfg(test)] + pub(crate) fn without_proxy(user_agent: &str) -> Self { + Self::build(user_agent, None, None).expect("client without proxy") + } + + fn build( + user_agent: &str, + env_proxy: Option, + system: Option, + ) -> Result { + let direct = make_agent(user_agent, env_proxy); + let proxied = match system { + Some(proxy) => match Proxy::new(&proxy.url) { + Ok(p) => Some(Proxied { + agent: make_agent(user_agent, Some(p)), + proxy, + }), + Err(err) => { + // The proxy URL is not logged: it may carry credentials. + log::warn!("system proxy ignored, it is not a valid proxy URI: {err}"); + None + } + }, + None => None, + }; + Ok(Self { direct, proxied }) + } + + /// The system proxy this client routes through, if any. The updater + /// reuses it so both paths reach the network the same way. + pub fn system_proxy(&self) -> Option<&SystemProxy> { + self.proxied.as_ref().map(|p| &p.proxy) + } + + pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> { + RequestBuilder::new(self, http::Method::GET, url) + } + + pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> { + RequestBuilder::new(self, http::Method::POST, url) + } + + /// The agent for `uri`: direct when the host matches a bypass entry or no + /// system proxy is configured, proxied otherwise. + fn agent_for(&self, uri: &Uri) -> &Agent { + match &self.proxied { + Some(p) if !uri.host().is_some_and(|h| p.proxy.bypasses(h)) => &p.agent, + _ => &self.direct, + } + } +} + +fn make_agent(user_agent: &str, proxy: Option) -> Agent { + let config = Agent::config_builder() + .http_status_as_error(false) + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_recv_response(Some(RECEIVE_TIMEOUT)) + .timeout_recv_body(Some(RECEIVE_TIMEOUT)) + .timeout_global(Some(GLOBAL_TIMEOUT)) + .user_agent(user_agent) + .proxy(proxy) + .tls_config( + TlsConfig::builder() + .root_certs(RootCerts::PlatformVerifier) + .build(), + ) + .build(); + Agent::new_with_config(config) +} + +pub struct RequestBuilder<'a> { + client: &'a Client, + method: http::Method, + url: String, + headers: Vec<(String, String)>, + body: Option>, +} + +impl<'a> RequestBuilder<'a> { + fn new(client: &'a Client, method: http::Method, url: &str) -> Self { + Self { + client, + method, + url: url.to_string(), + headers: Vec::new(), + body: None, + } + } + + /// Add a header. A `User-Agent` header replaces the client default. + pub fn header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.to_string(), value.to_string())); + self + } + + pub fn json_body(mut self, body: &T) -> Result { + self.body = Some(serde_json::to_vec(body)?); + self.headers + .push(("Content-Type".into(), "application/json".into())); + Ok(self) + } + + pub fn send(self) -> Result { + let uri: Uri = self + .url + .parse() + .map_err(|err| Error::Transport(format!("invalid URL: {err}")))?; + let agent = self.client.agent_for(&uri); + let mut builder = http::Request::builder().method(self.method).uri(uri); + for (name, value) in &self.headers { + builder = builder.header(name.as_str(), value.as_str()); + } + let response = match self.body { + Some(body) => agent.run(builder.body(body)?)?, + None => agent.run(builder.body(())?)?, + }; + let status = u32::from(response.status().as_u16()); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let body = response.into_body().read_to_vec()?; + Ok(Response { + status, + headers, + body, + }) + } +} + +pub struct Response { + status: u32, + headers: Vec<(String, String)>, + body: Vec, +} + +impl Response { + /// Build a response by hand, for tests of code that inspects responses. + #[cfg(test)] + pub(crate) fn from_parts(status: u32, headers: &[(&str, &str)], body: &[u8]) -> Self { + Self { + status, + headers: headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + body: body.to_vec(), + } + } + + pub fn status(&self) -> u32 { + self.status + } + + /// First header named `name`, compared case-insensitively. + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + pub fn body(&self) -> &[u8] { + &self.body + } + + pub fn text(&self) -> Result<&str, Error> { + std::str::from_utf8(&self.body).map_err(|_| Error::Utf8) + } + + pub fn json(&self) -> Result { + Ok(serde_json::from_slice(&self.body)?) + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::time::Duration; + + use super::test_server::serve_once; + use super::*; + + fn direct_client() -> Client { + Client::without_proxy(USER_AGENT) + } + + fn received(served: &test_server::Served) -> String { + served + .request + .recv_timeout(Duration::from_secs(10)) + .expect("server saw the request") + } + + #[test] + fn error_status_is_a_response_with_headers() { + let served = serve_once( + "429 Too Many Requests", + &[("anthropic-ratelimit-unified-5h-utilization", "0.5")], + b"{\"error\":\"rate_limited\"}".to_vec(), + ); + let resp = direct_client() + .get(&format!("{}/v1/messages", served.base)) + .send() + .expect("429 is not a transport error"); + assert_eq!(resp.status(), 429); + assert_eq!( + resp.header("Anthropic-Ratelimit-Unified-5h-Utilization"), + Some("0.5") + ); + assert_eq!(resp.text().expect("utf8"), "{\"error\":\"rate_limited\"}"); + received(&served); + } + + #[test] + fn sends_default_user_agent() { + let served = serve_once("200 OK", &[], Vec::new()); + direct_client().get(&served.base).send().expect("send"); + let request = received(&served).to_ascii_lowercase(); + let expected = format!("user-agent: {}\r\n", USER_AGENT.to_ascii_lowercase()); + assert!(request.contains(&expected), "{request}"); + assert!(USER_AGENT.starts_with("claude-code-usage-bubble/")); + } + + #[test] + fn per_request_user_agent_replaces_default() { + let served = serve_once("200 OK", &[], Vec::new()); + direct_client() + .get(&served.base) + .header("User-Agent", "codex-cli") + .send() + .expect("send"); + let request = received(&served).to_ascii_lowercase(); + assert!(request.contains("user-agent: codex-cli\r\n"), "{request}"); + assert_eq!(request.matches("user-agent:").count(), 1, "{request}"); + } + + #[test] + fn json_body_is_posted_with_content_type() { + let served = serve_once("200 OK", &[], b"{\"ok\":true}".to_vec()); + let resp = direct_client() + .post(&served.base) + .json_body(&serde_json::json!({"max_tokens": 1})) + .and_then(RequestBuilder::send) + .expect("send"); + let value: serde_json::Value = resp.json().expect("json"); + assert_eq!(value["ok"], true); + let request = received(&served); + assert!(request.starts_with("POST / HTTP/1.1\r\n"), "{request}"); + assert!(request + .to_ascii_lowercase() + .contains("content-type: application/json\r\n")); + assert!(request.ends_with("{\"max_tokens\":1}"), "{request}"); + } + + #[test] + fn gzip_body_is_decompressed() { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(b"plain text").expect("compress"); + let gz = encoder.finish().expect("finish"); + let served = serve_once("200 OK", &[("Content-Encoding", "gzip")], gz); + let resp = direct_client().get(&served.base).send().expect("send"); + assert_eq!(resp.body(), b"plain text"); + let request = received(&served).to_ascii_lowercase(); + assert!(request.contains("accept-encoding: gzip"), "{request}"); + } + + #[test] + fn transport_failure_is_an_error() { + let served = serve_once("200 OK", &[], Vec::new()); + let base = served.base.clone(); + // Free the port by completing the one request the server accepts. + direct_client().get(&base).send().expect("send"); + received(&served); + let err = direct_client() + .get(&base) + .send() + .err() + .expect("closed port fails"); + assert!(matches!(err, Error::Transport(_)), "{err}"); + assert!(matches!( + direct_client().get("not a url").send(), + Err(Error::Transport(_)) + )); + } + + #[test] + fn system_proxy_is_used_unless_the_host_is_bypassed() { + let proxy = serve_once("200 OK", &[], Vec::new()); + let origin = serve_once("200 OK", &[], b"direct".to_vec()); + let system = SystemProxy { + url: proxy.base.clone(), + bypass: Vec::new(), + }; + let client = Client::build(USER_AGENT, None, Some(system.clone())).expect("client"); + assert_eq!(client.system_proxy(), Some(&system)); + // The fake proxy does not tunnel, so the request itself fails; what + // matters is that it went to the proxy as a CONNECT for the origin. + let _ = client.get(&format!("{}/x", origin.base)).send(); + let request = received(&proxy); + let authority = origin.base.trim_start_matches("http://"); + assert!( + request.starts_with(&format!("CONNECT {authority} HTTP/1.1")), + "{request}" + ); + + let bypassing = Client::build( + USER_AGENT, + None, + Some(SystemProxy { + url: proxy.base.clone(), + bypass: vec!["127.0.0.*".into()], + }), + ) + .expect("client"); + let resp = bypassing.get(&origin.base).send().expect("send"); + assert_eq!(resp.body(), b"direct"); + received(&origin); + } + + #[test] + fn invalid_system_proxy_falls_back_to_direct() { + let client = Client::build( + USER_AGENT, + None, + Some(SystemProxy { + url: "http://bad host:notaport".into(), + bypass: Vec::new(), + }), + ) + .expect("client"); + assert_eq!(client.system_proxy(), None); + } +} + +#[cfg(test)] +pub(crate) mod test_server { + //! A one-shot HTTP/1.1 server on loopback that returns a canned reply and + //! hands the raw request back to the test. + + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + pub struct Served { + /// `http://127.0.0.1:`. + pub base: String, + /// Raw request head (request line plus headers), then the body. + pub request: mpsc::Receiver, + } + + /// Serve one request with `status`, `headers` and `body`. + pub fn serve_once(status: &str, headers: &[(&str, &str)], body: Vec) -> Served { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().expect("local addr").port(); + let mut head = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n", body.len()); + for (k, v) in headers { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str("Connection: close\r\n\r\n"); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let Ok((stream, _)) = listener.accept() else { + return; + }; + let mut reader = BufReader::new(stream); + let mut request = String::new(); + let mut content_length = 0usize; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + if let Some((k, v)) = line.split_once(':') { + if k.eq_ignore_ascii_case("content-length") { + content_length = v.trim().parse().unwrap_or(0); + } + } + request.push_str(&line); + if line == "\r\n" { + break; + } + } + let mut request_body = vec![0u8; content_length]; + let _ = reader.read_exact(&mut request_body); + request.push_str(&String::from_utf8_lossy(&request_body)); + let mut stream = reader.into_inner(); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + let _ = tx.send(request); + }); + Served { + base: format!("http://127.0.0.1:{port}"), + request: rx, + } + } +} diff --git a/src/net/winhttp.rs b/src/net/winhttp.rs deleted file mode 100644 index 0d7d6f4..0000000 --- a/src/net/winhttp.rs +++ /dev/null @@ -1,431 +0,0 @@ -// Minimal blocking HTTP client built on Win32 WinHTTP. -// -// One `Client` owns a session handle and is thread-safe (WinHTTP sessions -// can be used from multiple threads per MSDN). Each `send()` call manages -// its own connection + request handle lifetime via RAII guards so failures -// at any point clean up correctly. -// -// We deliberately do NOT use `WinHttpCrackUrl` — the small `parse_url` -// helper below is enough for the HTTPS URLs this app actually talks to and -// keeps the call sites simpler. - -use std::ffi::c_void; -use std::ptr::null_mut; - -use serde::de::DeserializeOwned; -use serde::Serialize; -use windows::core::PCWSTR; -use windows::Win32::Networking::WinHttp::*; - -use crate::os::string::to_utf16_nul; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("WinHTTP call failed: {context}")] - Win { context: String }, - #[error("invalid URL: {0}")] - Url(String), - #[error("response was not valid UTF-8")] - Utf8, - #[error("JSON parse: {0}")] - Json(#[from] serde_json::Error), - #[error("response status {0}")] - Status(u32), -} - -pub struct Client { - session: SessionHandle, -} - -// WinHTTP session handles are safe to use concurrently per the Microsoft docs -// (https://learn.microsoft.com/en-us/windows/win32/winhttp/winhttp-functions). -unsafe impl Send for Client {} -unsafe impl Sync for Client {} - -impl Client { - /// Create a new HTTP client. `user_agent` is sent on every request. - pub fn new(user_agent: &str) -> Result { - let ua = to_utf16_nul(user_agent); - let session = unsafe { - WinHttpOpen( - PCWSTR::from_raw(ua.as_ptr()), - WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, - PCWSTR::null(), - PCWSTR::null(), - 0, - ) - }; - if session.is_null() { - return Err(Error::Win { - context: "WinHttpOpen".into(), - }); - } - // Ask WinHTTP to decompress gzip/deflate transparently so callers - // get plain bytes back from `Response::body()`. Best-effort; if it - // fails the request still works, callers just see raw compressed - // bytes on responses that opt-in to compression. - unsafe { - let flags: u32 = WINHTTP_DECOMPRESSION_FLAG_GZIP | WINHTTP_DECOMPRESSION_FLAG_DEFLATE; - let flag_bytes = flags.to_ne_bytes(); - if let Err(e) = WinHttpSetOption( - Some(session as *const c_void), - WINHTTP_OPTION_DECOMPRESSION, - Some(&flag_bytes), - ) { - log::warn!("WinHttpSetOption(DECOMPRESSION) failed: {e}"); - } - } - Ok(Self { - session: SessionHandle(session), - }) - } - - pub fn get<'a>(&'a self, url: &str) -> RequestBuilder<'a> { - RequestBuilder::new(self, Method::Get, url) - } - - pub fn post<'a>(&'a self, url: &str) -> RequestBuilder<'a> { - RequestBuilder::new(self, Method::Post, url) - } -} - -#[derive(Clone, Copy)] -enum Method { - Get, - Post, -} - -impl Method { - fn verb(self) -> &'static str { - match self { - Method::Get => "GET", - Method::Post => "POST", - } - } -} - -pub struct RequestBuilder<'a> { - client: &'a Client, - method: Method, - url: String, - headers: Vec<(String, String)>, - body: Option>, -} - -impl<'a> RequestBuilder<'a> { - fn new(client: &'a Client, method: Method, url: &str) -> Self { - Self { - client, - method, - url: url.to_string(), - headers: Vec::new(), - body: None, - } - } - - pub fn header(mut self, name: &str, value: &str) -> Self { - self.headers.push((name.to_string(), value.to_string())); - self - } - - pub fn json_body(mut self, body: &T) -> Result { - self.body = Some(serde_json::to_vec(body)?); - self.headers - .push(("Content-Type".into(), "application/json".into())); - Ok(self) - } - - pub fn send(self) -> Result { - let parsed = parse_url(&self.url)?; - let host_w = to_utf16_nul(&parsed.host); - let path_w = to_utf16_nul(&parsed.path); - let verb_w = to_utf16_nul(self.method.verb()); - - let connect = unsafe { - WinHttpConnect( - self.client.session.0, - PCWSTR::from_raw(host_w.as_ptr()), - parsed.port, - 0, - ) - }; - if connect.is_null() { - return Err(Error::Win { - context: "WinHttpConnect".into(), - }); - } - let _connect_guard = HandleGuard(connect); - - let flags = if parsed.secure { - WINHTTP_FLAG_SECURE - } else { - WINHTTP_OPEN_REQUEST_FLAGS(0) - }; - let request = unsafe { - WinHttpOpenRequest( - connect, - PCWSTR::from_raw(verb_w.as_ptr()), - PCWSTR::from_raw(path_w.as_ptr()), - PCWSTR::null(), - PCWSTR::null(), - std::ptr::null::(), - flags, - ) - }; - if request.is_null() { - return Err(Error::Win { - context: "WinHttpOpenRequest".into(), - }); - } - let _request_guard = HandleGuard(request); - - // Combine headers into a single CRLF-separated string. The binding - // takes a UTF-16 slice; length is derived from the slice and no - // trailing NUL is required. - if !self.headers.is_empty() { - let header_str = self - .headers - .iter() - .map(|(k, v)| format!("{k}: {v}")) - .collect::>() - .join("\r\n"); - let header_w: Vec = header_str.encode_utf16().collect(); - unsafe { - WinHttpAddRequestHeaders( - request, - &header_w[..], - WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE, - ) - } - .map_err(|e| Error::Win { - context: format!("WinHttpAddRequestHeaders: {e}"), - })?; - } - - // Send body if present. dwTotalLength = body length; dwOptionalLength - // mirrors it for synchronous sends with the buffer included up front. - let body_bytes: &[u8] = self.body.as_deref().unwrap_or(&[]); - let body_ptr = if body_bytes.is_empty() { - None - } else { - Some(body_bytes.as_ptr() as *const c_void) - }; - let body_len = body_bytes.len() as u32; - unsafe { - WinHttpSendRequest(request, None, body_ptr, body_len, body_len, 0) - } - .map_err(|e| Error::Win { - context: format!("WinHttpSendRequest: {e}"), - })?; - - unsafe { WinHttpReceiveResponse(request, null_mut()) }.map_err(|e| Error::Win { - context: format!("WinHttpReceiveResponse: {e}"), - })?; - - let status = query_status_code(request)?; - let headers = query_raw_headers(request)?; - let body = read_body(request)?; - - Ok(Response { - status, - headers, - body, - }) - } -} - -pub struct Response { - status: u32, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Response { - pub fn status(&self) -> u32 { - self.status - } - - pub fn header(&self, name: &str) -> Option<&str> { - self.headers - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) - } - - pub fn body(&self) -> &[u8] { - &self.body - } - - pub fn text(&self) -> Result<&str, Error> { - std::str::from_utf8(&self.body).map_err(|_| Error::Utf8) - } - - pub fn json(&self) -> Result { - Ok(serde_json::from_slice(&self.body)?) - } -} - -// ---------- Low-level helpers ---------- - -struct SessionHandle(*mut c_void); - -impl Drop for SessionHandle { - fn drop(&mut self) { - if !self.0.is_null() { - unsafe { - let _ = WinHttpCloseHandle(self.0); - } - } - } -} - -struct HandleGuard(*mut c_void); - -impl Drop for HandleGuard { - fn drop(&mut self) { - if !self.0.is_null() { - unsafe { - let _ = WinHttpCloseHandle(self.0); - } - } - } -} - -fn query_status_code(request: *mut c_void) -> Result { - let mut status: u32 = 0; - let mut size: u32 = std::mem::size_of::() as u32; - unsafe { - WinHttpQueryHeaders( - request, - WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, - PCWSTR::null(), - Some((&mut status as *mut u32) as *mut c_void), - &mut size, - std::ptr::null_mut(), - ) - } - .map_err(|e| Error::Win { - context: format!("WinHttpQueryHeaders(STATUS_CODE): {e}"), - })?; - Ok(status) -} - -fn query_raw_headers(request: *mut c_void) -> Result, Error> { - // First call sizes the buffer (returns Err with ERROR_INSUFFICIENT_BUFFER - // and writes the required byte count to `needed`). - let mut needed: u32 = 0; - let _ = unsafe { - WinHttpQueryHeaders( - request, - WINHTTP_QUERY_RAW_HEADERS_CRLF, - PCWSTR::null(), - None, - &mut needed, - std::ptr::null_mut(), - ) - }; - if needed == 0 { - return Ok(Vec::new()); - } - let chars = (needed as usize) / std::mem::size_of::(); - let mut buf: Vec = vec![0u16; chars]; - unsafe { - WinHttpQueryHeaders( - request, - WINHTTP_QUERY_RAW_HEADERS_CRLF, - PCWSTR::null(), - Some(buf.as_mut_ptr() as *mut c_void), - &mut needed, - std::ptr::null_mut(), - ) - } - .map_err(|e| Error::Win { - context: format!("WinHttpQueryHeaders(RAW_HEADERS_CRLF): {e}"), - })?; - let text = String::from_utf16_lossy(&buf[..chars.saturating_sub(1)]); - Ok(parse_header_block(&text)) -} - -fn parse_header_block(block: &str) -> Vec<(String, String)> { - let mut out = Vec::new(); - let mut lines = block.split("\r\n"); - let _ = lines.next(); // status line, e.g. "HTTP/1.1 200 OK" - for line in lines { - if line.is_empty() { - continue; - } - if let Some((k, v)) = line.split_once(':') { - out.push((k.trim().to_string(), v.trim().to_string())); - } - } - out -} - -fn read_body(request: *mut c_void) -> Result, Error> { - let mut body = Vec::new(); - loop { - let mut available: u32 = 0; - unsafe { WinHttpQueryDataAvailable(request, &mut available) }.map_err(|e| Error::Win { - context: format!("WinHttpQueryDataAvailable: {e}"), - })?; - if available == 0 { - break; - } - let mut chunk = vec![0u8; available as usize]; - let mut read: u32 = 0; - unsafe { - WinHttpReadData( - request, - chunk.as_mut_ptr() as *mut c_void, - available, - &mut read, - ) - } - .map_err(|e| Error::Win { - context: format!("WinHttpReadData: {e}"), - })?; - chunk.truncate(read as usize); - body.append(&mut chunk); - } - Ok(body) -} - -// ---------- URL parsing ---------- - -struct ParsedUrl { - host: String, - port: u16, - path: String, - secure: bool, -} - -fn parse_url(url: &str) -> Result { - let (scheme, rest) = url - .split_once("://") - .ok_or_else(|| Error::Url(url.to_string()))?; - let secure = match scheme.to_ascii_lowercase().as_str() { - "https" => true, - "http" => false, - other => return Err(Error::Url(format!("unsupported scheme: {other}"))), - }; - let (host_port, path) = match rest.find('/') { - Some(i) => (&rest[..i], &rest[i..]), - None => (rest, "/"), - }; - let (host, port) = match host_port.rsplit_once(':') { - Some((h, p)) => ( - h.to_string(), - p.parse::().map_err(|_| Error::Url(url.to_string()))?, - ), - None => (host_port.to_string(), if secure { 443 } else { 80 }), - }; - if host.is_empty() { - return Err(Error::Url(url.to_string())); - } - Ok(ParsedUrl { - host, - port, - path: path.to_string(), - secure, - }) -} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..baa8729 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,5 @@ +//! OS-specific code. Each platform file is compiled only on its own target, +//! and portable callers reach it through `cfg`-gated calls. + +#[cfg(windows)] +pub mod windows; diff --git a/src/platform/windows.rs b/src/platform/windows.rs new file mode 100644 index 0000000..055bf98 --- /dev/null +++ b/src/platform/windows.rs @@ -0,0 +1,18 @@ +//! Windows-only helpers. This is the only module that talks to Win32 APIs. + +use crate::core::proxy::{self, SystemProxy}; + +const INTERNET_SETTINGS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; + +/// The static proxy from `HKCU\…\Internet Settings`, or `None` when +/// `ProxyEnable` is not 1 or no usable `ProxyServer` entry exists. +/// PAC/WPAD (`AutoConfigURL`, auto-detect) is deliberately not evaluated. +pub fn system_proxy() -> Option { + let key = windows_registry::CURRENT_USER + .open(INTERNET_SETTINGS_KEY) + .ok()?; + let enable = key.get_u32("ProxyEnable").ok(); + let server = key.get_string("ProxyServer").ok(); + let proxy_override = key.get_string("ProxyOverride").ok(); + proxy::from_registry_values(enable, server.as_deref(), proxy_override.as_deref()) +} diff --git a/src/usage/anthropic.rs b/src/usage/anthropic.rs index 102872f..718869f 100644 --- a/src/usage/anthropic.rs +++ b/src/usage/anthropic.rs @@ -17,10 +17,7 @@ const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages"; const BETA_HEADER: &str = "oauth-2025-04-20"; const API_VERSION: &str = "2023-06-01"; -const PROBE_MODELS: &[&str] = &[ - "claude-3-haiku-20240307", - "claude-haiku-4-5-20251001", -]; +const PROBE_MODELS: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"]; pub struct ClaudeProvider { locator: Locator, @@ -86,13 +83,16 @@ fn try_usage_endpoint(http: &Client, token: &str) -> Result if !(200..300).contains(&resp.status()) { return Ok(None); } - let body: OauthUsage = match resp.json() { - Ok(b) => b, - Err(_) => return Ok(None), - }; + Ok(parse_usage_body(resp.body())) +} + +/// Windows from an `/api/oauth/usage` body, or `None` when the body does not +/// match the expected shape (which sends the caller to the Messages fallback). +fn parse_usage_body(body: &[u8]) -> Option { + let body: OauthUsage = serde_json::from_slice(body).ok()?; let primary = body.five_hour.map(bucket_to_window).unwrap_or_default(); let secondary = body.seven_day.map(bucket_to_window).unwrap_or_default(); - Ok(Some(UsageWindows { primary, secondary })) + Some(UsageWindows { primary, secondary }) } fn try_messages_endpoint(http: &Client, token: &str) -> Result { @@ -120,8 +120,12 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result Option { } fn is_leap(year: u64) -> bool { - (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) } // --- JSON shape --- @@ -256,3 +260,125 @@ struct Bucket { utilization: f64, resets_at: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + fn at(secs: u64) -> Option { + Some(UNIX_EPOCH + Duration::from_secs(secs)) + } + + fn fixture(name: &str) -> Vec { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}")) + } + + #[test] + fn full_usage_body_maps_both_buckets() { + let w = parse_usage_body(&fixture("anthropic-usage-full.json")).expect("parses"); + assert_eq!(w.primary.utilization, 42.5); + assert_eq!(w.primary.resets_at, at(1_761_040_800)); + assert_eq!(w.secondary.utilization, 100.0, "clamped to 100"); + assert_eq!(w.secondary.resets_at, at(1_761_508_800)); + assert!(has_reset_times(&w)); + } + + #[test] + fn partial_usage_body_keeps_percentages_and_merges_fallback_resets() { + let w = parse_usage_body(&fixture("anthropic-usage-partial.json")).expect("parses"); + assert_eq!(w.primary.utilization, 7.0); + assert_eq!(w.primary.resets_at, None); + assert_eq!(w.secondary.resets_at, at(1_709_251_199)); + assert!(!has_reset_times(&w)); + + let fallback = UsageWindows { + primary: Window { + utilization: 99.0, + resets_at: at(10), + }, + secondary: Window { + utilization: 99.0, + resets_at: at(20), + }, + }; + let merged = merge_resets(w, fallback); + assert_eq!(merged.primary.utilization, 7.0, "percent is not replaced"); + assert_eq!(merged.primary.resets_at, at(10)); + assert_eq!(merged.secondary.resets_at, at(1_709_251_199)); + } + + #[test] + fn missing_bucket_defaults_to_empty_window() { + let w = parse_usage_body(&fixture("anthropic-usage-five-hour-only.json")).expect("parses"); + assert_eq!(w.primary.utilization, 3.0); + assert_eq!(w.secondary.utilization, 0.0); + assert_eq!(w.secondary.resets_at, None); + } + + #[test] + fn null_buckets_parse_as_empty_windows() { + let w = parse_usage_body(&fixture("anthropic-usage-null-buckets.json")).expect("parses"); + assert_eq!(w.primary.utilization, 0.0); + assert_eq!(w.secondary.utilization, 0.0); + assert!(!has_reset_times(&w)); + } + + #[test] + fn null_utilization_rejects_body_so_fallback_runs() { + assert!(parse_usage_body(&fixture("anthropic-usage-null-utilization.json")).is_none()); + assert!(parse_usage_body(b"not json").is_none()); + } + + #[test] + fn iso8601_accepts_utc_offsets_and_fractions() { + assert_eq!(parse_iso8601("2025-10-21T10:00:00Z"), at(1_761_040_800)); + assert_eq!( + parse_iso8601("2025-10-21T10:00:00.999999+00:00"), + at(1_761_040_800) + ); + assert_eq!( + parse_iso8601("2025-10-27T03:00:00+07:00"), + at(1_761_508_800) + ); + assert_eq!( + parse_iso8601("2025-10-21T05:30:00-04:30"), + at(1_761_040_800) + ); + assert_eq!(parse_iso8601("2025-10-21T10:00:00"), at(1_761_040_800)); + assert_eq!(parse_iso8601("2024-02-29T23:59:59Z"), at(1_709_251_199)); + assert_eq!(parse_iso8601("1970-01-01T00:00:00Z"), at(0)); + } + + #[test] + fn iso8601_rejects_malformed_values() { + assert_eq!( + parse_iso8601("2025-10-21T10:00:00+0000"), + None, + "colonless offset" + ); + assert_eq!(parse_iso8601("2025-10-21t10:00:00Z"), None, "lowercase t"); + assert_eq!(parse_iso8601("1969-12-31T23:59:59Z"), None, "before 1970"); + assert_eq!( + parse_iso8601("1970-01-01T00:30:00+01:00"), + None, + "negative epoch" + ); + assert_eq!( + parse_iso8601("2025-02-29T00:00:00Z"), + None, + "not a leap year" + ); + assert_eq!(parse_iso8601("2025-13-01T00:00:00Z"), None); + assert_eq!(parse_iso8601("2025-10-21 10:00:00Z"), None); + assert_eq!(parse_iso8601("2025-10-21T10:00Z"), None); + assert_eq!(parse_iso8601(""), None); + } + + #[test] + fn token_expiry_compares_milliseconds() { + assert!(!token_is_expired(None)); + assert!(token_is_expired(Some(0))); + assert!(!token_is_expired(Some(i64::MAX))); + } +} diff --git a/src/usage/chatgpt.rs b/src/usage/chatgpt.rs index 51d152a..cd744ca 100644 --- a/src/usage/chatgpt.rs +++ b/src/usage/chatgpt.rs @@ -125,3 +125,52 @@ impl FlattenBoxed for Option>> { self.and_then(|inner| inner.map(|b| *b)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn at(secs: u64) -> Option { + Some(UNIX_EPOCH + Duration::from_secs(secs)) + } + + fn envelope(name: &str) -> Envelope { + let path = format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")); + serde_json::from_str(&text).expect("fixture parses") + } + + #[test] + fn full_envelope_maps_both_windows() { + let w = envelope_to_windows(envelope("chatgpt-usage-full.json")).expect("rate_limit"); + assert_eq!(w.primary.utilization, 12.0); + assert_eq!(w.primary.resets_at, at(1_761_040_800)); + assert_eq!(w.secondary.utilization, 100.0, "clamped to 100"); + assert_eq!(w.secondary.resets_at, at(1_761_508_800)); + } + + #[test] + fn null_rate_limit_is_a_bad_response() { + assert!(envelope_to_windows(envelope("chatgpt-usage-null-rate-limit.json")).is_none()); + let missing: Envelope = serde_json::from_str("{}").expect("parses"); + assert!(envelope_to_windows(missing).is_none()); + } + + #[test] + fn null_window_defaults_to_empty() { + let w = + envelope_to_windows(envelope("chatgpt-usage-null-windows.json")).expect("rate_limit"); + assert_eq!(w.primary.utilization, 55.0); + assert_eq!(w.secondary.utilization, 0.0); + assert_eq!(w.secondary.resets_at, None); + } + + #[test] + fn negative_reset_has_no_reset_time_and_percent_is_clamped() { + let w = + envelope_to_windows(envelope("chatgpt-usage-negative-reset.json")).expect("rate_limit"); + assert_eq!(w.primary.utilization, 0.0); + assert_eq!(w.primary.resets_at, None); + assert_eq!(w.secondary.resets_at, at(0)); + } +} diff --git a/src/usage/headers.rs b/src/usage/headers.rs index bedde0d..f7c5233 100644 --- a/src/usage/headers.rs +++ b/src/usage/headers.rs @@ -53,3 +53,95 @@ fn unix_to_systemtime(secs: Option) -> Option { } Some(UNIX_EPOCH + Duration::from_secs(s as u64)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::test_server::serve_once; + use crate::net::{Client, USER_AGENT}; + + fn at(secs: u64) -> Option { + Some(UNIX_EPOCH + Duration::from_secs(secs)) + } + + #[test] + fn fractions_scale_to_percent_and_resets_parse() { + let resp = Response::from_parts( + 200, + &[ + ("anthropic-ratelimit-unified-5h-utilization", "0.425"), + ("anthropic-ratelimit-unified-5h-reset", "1761040800"), + ("Anthropic-Ratelimit-Unified-7d-Utilization", "1.2"), + ("anthropic-ratelimit-unified-7d-reset", "1761508800"), + ], + b"", + ); + let w = parse_anthropic(&resp); + assert!((w.primary.utilization - 42.5).abs() < 1e-9); + assert_eq!(w.primary.resets_at, at(1_761_040_800)); + assert_eq!(w.secondary.utilization, 100.0, "clamped to 100"); + assert_eq!(w.secondary.resets_at, at(1_761_508_800)); + } + + #[test] + fn missing_or_garbage_values_fall_back_to_empty() { + let resp = Response::from_parts( + 200, + &[ + ("anthropic-ratelimit-unified-5h-utilization", "abc"), + ("anthropic-ratelimit-unified-5h-reset", "soon"), + ], + b"", + ); + let w = parse_anthropic(&resp); + assert_eq!(w.primary.utilization, 0.0); + assert_eq!(w.primary.resets_at, None); + assert_eq!(w.secondary.utilization, 0.0); + assert_eq!(w.secondary.resets_at, None); + } + + #[test] + fn negative_values_clamp_and_drop_reset() { + let resp = Response::from_parts( + 200, + &[ + ("anthropic-ratelimit-unified-5h-utilization", "-0.3"), + ("anthropic-ratelimit-unified-5h-reset", "-1"), + ("anthropic-ratelimit-unified-7d-utilization", "0.5"), + ("anthropic-ratelimit-unified-7d-reset", "0"), + ], + b"", + ); + let w = parse_anthropic(&resp); + assert_eq!(w.primary.utilization, 0.0); + assert_eq!(w.primary.resets_at, None); + assert_eq!(w.secondary.utilization, 50.0); + assert_eq!(w.secondary.resets_at, at(0)); + } + + #[test] + fn rate_limited_reply_still_yields_headers_over_http() { + let served = serve_once( + "429 Too Many Requests", + &[ + ("anthropic-ratelimit-unified-5h-utilization", "1.0"), + ("anthropic-ratelimit-unified-5h-reset", "1761040800"), + ("anthropic-ratelimit-unified-7d-utilization", "0.25"), + ("anthropic-ratelimit-unified-7d-reset", "1761508800"), + ], + br#"{"type":"error","error":{"type":"rate_limit_error"}}"#.to_vec(), + ); + let client = Client::without_proxy(USER_AGENT); + let resp = client + .post(&served.base) + .json_body(&serde_json::json!({"max_tokens": 1})) + .and_then(crate::net::RequestBuilder::send) + .expect("429 is a response, not an error"); + assert_eq!(resp.status(), 429); + let w = parse_anthropic(&resp); + assert_eq!(w.primary.utilization, 100.0); + assert_eq!(w.primary.resets_at, at(1_761_040_800)); + assert_eq!(w.secondary.utilization, 25.0); + assert_eq!(w.secondary.resets_at, at(1_761_508_800)); + } +} diff --git a/src/usage/mod.rs b/src/usage/mod.rs index c25cbb2..58c854d 100644 --- a/src/usage/mod.rs +++ b/src/usage/mod.rs @@ -1,7 +1,35 @@ -// Usage subsystem. Only the shared data shapes are compiled for now; the -// providers, registry, headers and refresh orchestrator return with the -// HTTP client port. +// Usage subsystem: the provider trait, the per-provider implementations, +// the registry that polls them and the credential refresh orchestrator. +pub mod anthropic; +pub mod chatgpt; +pub mod headers; +pub mod refresh; +pub mod registry; pub mod types; +pub use registry::Registry; pub use types::{ProviderId, UsageWindows, Window}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("authentication required")] + AuthRequired, + #[error("no credentials configured")] + NoCredentials, + #[error("token expired after refresh attempt")] + TokenExpired, + #[error("network: {0}")] + Network(#[from] crate::net::Error), + #[error("credentials: {0}")] + Creds(#[from] crate::creds::Error), + #[error("unexpected response shape: {0}")] + BadResponse(String), +} + +/// Every provider exposes a stable identity and a sync `poll` that performs +/// HTTP calls against the supplied client. +pub trait UsageProvider: Send { + fn id(&self) -> ProviderId; + fn poll(&mut self, http: &crate::net::Client) -> Result; +} diff --git a/src/usage/refresh.rs b/src/usage/refresh.rs index 5a28cf1..010a471 100644 --- a/src/usage/refresh.rs +++ b/src/usage/refresh.rs @@ -4,14 +4,22 @@ // which CLI to spawn. We invoke that CLI and watch the credential file's // signature; if it changes within the timeout we declare success. -use std::os::windows::process::CommandExt; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use crate::creds::{CredentialSource, RefreshHint}; +#[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +/// Directories searched after `PATH` on Unix. A desktop session often starts +/// GUI apps with a shorter `PATH` than the login shell, which misses the +/// usual per-user install locations of the Claude and Codex CLIs. +#[cfg(unix)] +const EXTRA_HOME_DIRS: &[&str] = &[".local/bin", ".claude/local"]; +#[cfg(unix)] +const EXTRA_SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Outcome { Refreshed, @@ -55,54 +63,232 @@ impl Orchestrator { } fn spawn_cli(hint: &RefreshHint) -> bool { + let (candidates, args) = cli_for(hint); + spawn_first(candidates, args) +} + +#[cfg(windows)] +fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) { match hint { - RefreshHint::LocalClaudeCli => { - spawn_local(&["claude.cmd", "claude.exe", "claude"], &["-p", "."]) - } - RefreshHint::LocalCodexCli => spawn_local( + RefreshHint::LocalClaudeCli => (&["claude.cmd", "claude.exe", "claude"], &["-p", "."]), + RefreshHint::LocalCodexCli => ( &["codex.cmd", "codex.ps1", "codex.exe", "codex"], &["exec", "."], ), } } -fn spawn_local(candidates: &[&str], args: &[&str]) -> bool { +#[cfg(unix)] +fn cli_for(hint: &RefreshHint) -> (&'static [&'static str], &'static [&'static str]) { + match hint { + RefreshHint::LocalClaudeCli => (&["claude"], &["-p", "."]), + RefreshHint::LocalCodexCli => (&["codex"], &["exec", "."]), + } +} + +/// Spawn the first candidate that starts. The child is detached: a reaper +/// thread waits on it so no zombie is left behind. +fn spawn_first(candidates: &[&str], args: &[&str]) -> bool { for name in candidates { - let lower = name.to_ascii_lowercase(); - let mut cmd = if lower.ends_with(".ps1") { - let mut c = Command::new("powershell.exe"); - c.arg("-NoProfile") - .arg("-ExecutionPolicy") - .arg("Bypass") - .arg("-File") - .arg(name); - for a in args { - c.arg(a); - } - c - } else if lower.ends_with(".cmd") || lower.ends_with(".bat") { - let mut c = Command::new("cmd.exe"); - c.arg("/c").arg(name); - for a in args { - c.arg(a); - } - c - } else { - let mut c = Command::new(name); - for a in args { - c.arg(a); - } - c + let Some(mut cmd) = command_for(name, args) else { + continue; }; cmd.env_remove("CLAUDECODE") .env_remove("CLAUDE_CODE_ENTRYPOINT") - .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - if cmd.spawn().is_ok() { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NO_WINDOW); + } + if let Ok(child) = cmd.spawn() { + reap_detached(child); return true; } } false } + +fn reap_detached(mut child: Child) { + std::thread::spawn(move || { + if let Err(err) = child.wait() { + log::warn!("refresh CLI could not be reaped: {err}"); + } + }); +} + +#[cfg(windows)] +fn command_for(name: &str, args: &[&str]) -> Option { + let lower = name.to_ascii_lowercase(); + let mut cmd = if lower.ends_with(".ps1") { + let mut c = Command::new("powershell.exe"); + c.arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(name); + c + } else if lower.ends_with(".cmd") || lower.ends_with(".bat") { + let mut c = Command::new("cmd.exe"); + c.arg("/c").arg(name); + c + } else { + Command::new(name) + }; + cmd.args(args); + Some(cmd) +} + +#[cfg(unix)] +fn command_for(name: &str, args: &[&str]) -> Option { + let path_var = std::env::var_os("PATH"); + let home = dirs::home_dir(); + let dirs = search_dirs(path_var.as_deref(), home.as_deref()); + let program = find_executable(name, &dirs)?; + let mut cmd = Command::new(program); + cmd.args(args); + Some(cmd) +} + +/// `PATH` entries in order, then the extra install locations, without +/// duplicates. Empty and relative entries are skipped so the working +/// directory is never searched. +#[cfg(unix)] +fn search_dirs( + path_var: Option<&std::ffi::OsStr>, + home: Option<&std::path::Path>, +) -> Vec { + use std::path::PathBuf; + + let mut out: Vec = Vec::new(); + let from_path = path_var.map(|p| std::env::split_paths(p).collect::>()); + let extras_home = home + .into_iter() + .flat_map(|h| EXTRA_HOME_DIRS.iter().map(move |d| h.join(d))); + let extras_system = EXTRA_SYSTEM_DIRS.iter().map(PathBuf::from); + for dir in from_path + .unwrap_or_default() + .into_iter() + .chain(extras_home) + .chain(extras_system) + { + if dir.is_absolute() && !out.contains(&dir) { + out.push(dir); + } + } + out +} + +/// First `dir/name` that is a regular file with an execute bit set. +#[cfg(unix)] +fn find_executable(name: &str, dirs: &[std::path::PathBuf]) -> Option { + use std::os::unix::fs::PermissionsExt; + + dirs.iter().map(|d| d.join(name)).find(|candidate| { + std::fs::metadata(candidate) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }) +} + +#[cfg(all(test, unix))] +mod tests { + use std::ffi::OsString; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + + use super::*; + + fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ccub-refresh-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn write_file(path: &Path, mode: u32) { + std::fs::write(path, "#!/bin/sh\n").expect("write"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod"); + } + + #[test] + fn search_dirs_puts_path_first_then_extras_without_duplicates() { + let path = OsString::from("/usr/bin::relative/bin:/usr/local/bin:/usr/bin"); + let dirs = search_dirs(Some(&path), Some(Path::new("/home/u"))); + let expected: Vec = [ + "/usr/bin", + "/usr/local/bin", + "/home/u/.local/bin", + "/home/u/.claude/local", + "/opt/homebrew/bin", + ] + .iter() + .map(PathBuf::from) + .collect(); + assert_eq!(dirs, expected); + } + + #[test] + fn search_dirs_without_path_or_home_keeps_system_extras() { + let dirs = search_dirs(None, None); + assert_eq!( + dirs, + vec![ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin") + ] + ); + } + + #[test] + fn find_executable_skips_missing_and_non_executable_files() { + let first = temp_dir("first"); + let second = temp_dir("second"); + write_file(&first.join("claude"), 0o644); + std::fs::create_dir_all(first.join("codex")).expect("dir named like the CLI"); + write_file(&second.join("claude"), 0o755); + let dirs = vec![first.clone(), second.clone()]; + assert_eq!( + find_executable("claude", &dirs), + Some(second.join("claude")) + ); + assert_eq!(find_executable("codex", &dirs), None); + assert_eq!(find_executable("absent", &dirs), None); + let _ = std::fs::remove_dir_all(first); + let _ = std::fs::remove_dir_all(second); + } + + #[test] + fn spawned_child_is_reaped() { + let dir = temp_dir("spawn"); + let script = dir.join("fake-cli"); + let marker = dir.join("ran"); + std::fs::write( + &script, + format!("#!/bin/sh\necho \"$@\" > '{}'\n", marker.display()), + ) + .expect("write script"); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + let name = script.to_str().expect("utf8 path"); + assert!(spawn_first( + &["definitely-not-a-cli-name", name], + &["-p", "."] + )); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let written = std::fs::read_to_string(&marker).unwrap_or_default(); + assert_eq!(written.trim(), "-p ."); + assert!(!spawn_first(&["definitely-not-a-cli-name"], &[])); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/src/usage/registry.rs b/src/usage/registry.rs index 1cdf0c5..18cc62a 100644 --- a/src/usage/registry.rs +++ b/src/usage/registry.rs @@ -4,9 +4,9 @@ // every cycle. The result is a flat list of `(id, Result)` // pairs the app can apply to its state. +use crate::core::settings::Settings; use crate::creds::Locator; use crate::net::Client; -use crate::settings::Settings; use crate::usage::{ anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows, @@ -74,3 +74,49 @@ impl Registry { sigs } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::USER_AGENT; + + #[test] + fn live_usage_excludes_opencode_go() { + // `poll_enabled` relies on this for its `unreachable!` arm. + assert!(!ProviderId::LIVE_USAGE.contains(&ProviderId::OpenCodeGo)); + for provider in ProviderId::ALL { + assert_eq!( + ProviderId::LIVE_USAGE.contains(&provider), + provider.metadata().live_usage + ); + } + } + + /// Polls the real providers with the credentials on this machine. Run by + /// hand: `cargo test -- --ignored live_poll --nocapture`. + #[test] + #[ignore = "talks to the real provider endpoints"] + fn live_poll() { + let http = Client::new(USER_AGENT).expect("client"); + let mut settings = Settings::default(); + for provider in ProviderId::ALL { + settings.set_provider_enabled(provider, true); + } + let mut registry = Registry::with_defaults(); + let results = registry.poll_enabled(&http, &settings); + assert_eq!(results.len(), ProviderId::LIVE_USAGE.len()); + for (provider, result) in results { + match result { + Ok(w) => println!( + "{}: 5h {:.1}% resets {:?}, weekly {:.1}% resets {:?}", + provider.slug(), + w.primary.utilization, + w.primary.resets_at, + w.secondary.utilization, + w.secondary.resets_at + ), + Err(err) => println!("{}: {err}", provider.slug()), + } + } + } +} diff --git a/tests/fixtures/anthropic-usage-five-hour-only.json b/tests/fixtures/anthropic-usage-five-hour-only.json new file mode 100644 index 0000000..ee9b76b --- /dev/null +++ b/tests/fixtures/anthropic-usage-five-hour-only.json @@ -0,0 +1,3 @@ +{ + "five_hour": { "utilization": 3.0, "resets_at": "2025-10-21T10:00:00Z" } +} diff --git a/tests/fixtures/anthropic-usage-full.json b/tests/fixtures/anthropic-usage-full.json new file mode 100644 index 0000000..dad0651 --- /dev/null +++ b/tests/fixtures/anthropic-usage-full.json @@ -0,0 +1,6 @@ +{ + "five_hour": { "utilization": 42.5, "resets_at": "2025-10-21T10:00:00.123456+00:00" }, + "seven_day": { "utilization": 130.0, "resets_at": "2025-10-27T03:00:00+07:00" }, + "seven_day_opus": null, + "extra_usage": { "is_enabled": false } +} diff --git a/tests/fixtures/anthropic-usage-null-buckets.json b/tests/fixtures/anthropic-usage-null-buckets.json new file mode 100644 index 0000000..e608501 --- /dev/null +++ b/tests/fixtures/anthropic-usage-null-buckets.json @@ -0,0 +1,4 @@ +{ + "five_hour": null, + "seven_day": null +} diff --git a/tests/fixtures/anthropic-usage-null-utilization.json b/tests/fixtures/anthropic-usage-null-utilization.json new file mode 100644 index 0000000..2552523 --- /dev/null +++ b/tests/fixtures/anthropic-usage-null-utilization.json @@ -0,0 +1,4 @@ +{ + "five_hour": { "utilization": null, "resets_at": "2025-10-21T10:00:00Z" }, + "seven_day": { "utilization": 10.0, "resets_at": "2025-10-27T03:00:00+07:00" } +} diff --git a/tests/fixtures/anthropic-usage-partial.json b/tests/fixtures/anthropic-usage-partial.json new file mode 100644 index 0000000..7788874 --- /dev/null +++ b/tests/fixtures/anthropic-usage-partial.json @@ -0,0 +1,4 @@ +{ + "five_hour": { "utilization": 7.0, "resets_at": null }, + "seven_day": { "utilization": 61.0, "resets_at": "2024-02-29T23:59:59Z" } +} diff --git a/tests/fixtures/chatgpt-usage-full.json b/tests/fixtures/chatgpt-usage-full.json new file mode 100644 index 0000000..150f97c --- /dev/null +++ b/tests/fixtures/chatgpt-usage-full.json @@ -0,0 +1,20 @@ +{ + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 12, + "limit_window_seconds": 18000, + "reset_after_seconds": 3600, + "reset_at": 1761040800 + }, + "secondary_window": { + "used_percent": 104.5, + "limit_window_seconds": 604800, + "reset_after_seconds": 86400, + "reset_at": 1761508800 + } + }, + "credits": null +} diff --git a/tests/fixtures/chatgpt-usage-negative-reset.json b/tests/fixtures/chatgpt-usage-negative-reset.json new file mode 100644 index 0000000..f972d89 --- /dev/null +++ b/tests/fixtures/chatgpt-usage-negative-reset.json @@ -0,0 +1,6 @@ +{ + "rate_limit": { + "primary_window": { "used_percent": -5.0, "reset_at": -1 }, + "secondary_window": { "used_percent": 20.0, "reset_at": 0 } + } +} diff --git a/tests/fixtures/chatgpt-usage-null-rate-limit.json b/tests/fixtures/chatgpt-usage-null-rate-limit.json new file mode 100644 index 0000000..76e6baf --- /dev/null +++ b/tests/fixtures/chatgpt-usage-null-rate-limit.json @@ -0,0 +1,4 @@ +{ + "plan_type": "free", + "rate_limit": null +} diff --git a/tests/fixtures/chatgpt-usage-null-windows.json b/tests/fixtures/chatgpt-usage-null-windows.json new file mode 100644 index 0000000..8831e94 --- /dev/null +++ b/tests/fixtures/chatgpt-usage-null-windows.json @@ -0,0 +1,7 @@ +{ + "rate_limit": { + "allowed": true, + "primary_window": { "used_percent": 55.0, "reset_at": 1761040800 }, + "secondary_window": null + } +} diff --git a/tests/fixtures/claude-credentials-float-expiry.json b/tests/fixtures/claude-credentials-float-expiry.json new file mode 100644 index 0000000..388ab6a --- /dev/null +++ b/tests/fixtures/claude-credentials-float-expiry.json @@ -0,0 +1,3 @@ +{ + "claudeAiOauth": { "accessToken": "sk-ant-oat01-fixture", "expiresAt": 1761040800000.5 } +} diff --git a/tests/fixtures/claude-credentials-full.json b/tests/fixtures/claude-credentials-full.json new file mode 100644 index 0000000..2073db9 --- /dev/null +++ b/tests/fixtures/claude-credentials-full.json @@ -0,0 +1,9 @@ +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-fixture", + "refreshToken": "sk-ant-ort01-fixture", + "expiresAt": 1761040800000, + "scopes": ["user:inference", "user:profile"], + "subscriptionType": "pro" + } +} diff --git a/tests/fixtures/claude-credentials-missing-oauth.json b/tests/fixtures/claude-credentials-missing-oauth.json new file mode 100644 index 0000000..082f821 --- /dev/null +++ b/tests/fixtures/claude-credentials-missing-oauth.json @@ -0,0 +1,3 @@ +{ + "mcpOAuth": {} +} diff --git a/tests/fixtures/claude-credentials-missing-token.json b/tests/fixtures/claude-credentials-missing-token.json new file mode 100644 index 0000000..60812d5 --- /dev/null +++ b/tests/fixtures/claude-credentials-missing-token.json @@ -0,0 +1,3 @@ +{ + "claudeAiOauth": { "expiresAt": 1761040800000 } +} diff --git a/tests/fixtures/codex-auth-empty-access-token.json b/tests/fixtures/codex-auth-empty-access-token.json new file mode 100644 index 0000000..a7c48dc --- /dev/null +++ b/tests/fixtures/codex-auth-empty-access-token.json @@ -0,0 +1,3 @@ +{ + "tokens": { "access_token": "", "account_id": null } +} diff --git a/tests/fixtures/codex-auth-full.json b/tests/fixtures/codex-auth-full.json new file mode 100644 index 0000000..7ff74d8 --- /dev/null +++ b/tests/fixtures/codex-auth-full.json @@ -0,0 +1,10 @@ +{ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "fixture-id-token", + "access_token": "fixture-access-token", + "refresh_token": "fixture-refresh-token", + "account_id": "fixture-account" + }, + "last_refresh": "2025-10-21T10:00:00Z" +} diff --git a/tests/fixtures/codex-auth-missing-access-token.json b/tests/fixtures/codex-auth-missing-access-token.json new file mode 100644 index 0000000..00886a5 --- /dev/null +++ b/tests/fixtures/codex-auth-missing-access-token.json @@ -0,0 +1,3 @@ +{ + "tokens": { "account_id": "fixture-account" } +} diff --git a/tests/fixtures/codex-auth-null-tokens.json b/tests/fixtures/codex-auth-null-tokens.json new file mode 100644 index 0000000..56d1a83 --- /dev/null +++ b/tests/fixtures/codex-auth-null-tokens.json @@ -0,0 +1,4 @@ +{ + "OPENAI_API_KEY": "sk-fixture", + "tokens": null +}