Skip to main content

ai_usagebar/supergrok/
scope.rs

1//! Privacy-preserving cache scope for the Grok Build login.
2//!
3//! The billing ACP response intentionally contains no account identifier. To
4//! avoid serving one login's cached usage after `grok login` switches users,
5//! hash the auth and config files as opaque bytes. The digest is never shown;
6//! no token, e-mail address, user id, or raw configuration is copied to the
7//! ai-usagebar cache.
8
9use std::ffi::OsString;
10use std::fmt::Write as _;
11use std::fs::File;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14
15use sha2::{Digest, Sha256};
16
17use crate::cache::home_dir;
18use crate::error::Result;
19
20const MAX_SCOPE_FILE_BYTES: u64 = 2 * 1024 * 1024;
21const SCOPE_ENV: [&str; 8] = [
22    "GROK_OIDC_ISSUER",
23    "GROK_OIDC_CLIENT_ID",
24    "GROK_AUTH_PROVIDER_COMMAND",
25    "GROK_AUTH_TOKEN_TTL",
26    "GROK_CLI_CHAT_PROXY_BASE_URL",
27    "GROK_API_KEY",
28    "XAI_API_KEY",
29    "GROK_HOME",
30];
31
32#[derive(Debug, Clone)]
33pub struct ScopePaths {
34    pub auth: PathBuf,
35    pub config: PathBuf,
36}
37
38impl ScopePaths {
39    pub fn defaults() -> Result<Self> {
40        Self::defaults_with(std::env::var_os("GROK_HOME"), home_dir)
41    }
42
43    fn defaults_with<F>(grok_home: Option<OsString>, fallback_home: F) -> Result<Self>
44    where
45        F: FnOnce() -> Result<PathBuf>,
46    {
47        let grok_dir = match grok_home.filter(|value| !value.is_empty()) {
48            Some(path) => PathBuf::from(path),
49            None => fallback_home()?.join(".grok"),
50        };
51        Ok(Self {
52            auth: grok_dir.join("auth.json"),
53            config: grok_dir.join("config.toml"),
54        })
55    }
56
57    pub fn with_overrides(auth: Option<&Path>, config: Option<&Path>) -> Result<Self> {
58        if let (Some(auth), Some(config)) = (auth, config) {
59            return Ok(Self {
60                auth: auth.to_path_buf(),
61                config: config.to_path_buf(),
62            });
63        }
64        let mut paths = Self::defaults()?;
65        if let Some(auth) = auth {
66            paths.auth = auth.to_path_buf();
67        }
68        if let Some(config) = config {
69            paths.config = config.to_path_buf();
70        }
71        Ok(paths)
72    }
73}
74
75/// Return a stable opaque cache scope, or `None` when the login state cannot
76/// be read safely. `None` deliberately disables cache reuse rather than
77/// risking data from another login.
78pub fn fingerprint(paths: &ScopePaths) -> Option<String> {
79    fingerprint_with(paths, |name| std::env::var_os(name))
80}
81
82fn fingerprint_with<F>(paths: &ScopePaths, read_env: F) -> Option<String>
83where
84    F: Fn(&str) -> Option<OsString>,
85{
86    let FileState::Present(auth) = read_bounded(&paths.auth) else {
87        return None;
88    };
89    let config = read_bounded(&paths.config);
90
91    let mut hasher = Sha256::new();
92    hasher.update(b"ai-usagebar-supergrok-scope-v2\0auth\0");
93    hasher.update(&auth);
94    hasher.update(b"\0config\0");
95    match config {
96        FileState::Present(bytes) => {
97            hasher.update(b"present\0");
98            hasher.update(&bytes);
99        }
100        FileState::Missing => hasher.update(b"missing"),
101        FileState::Unavailable => return None,
102    }
103    for name in SCOPE_ENV {
104        hasher.update(b"\0env\0");
105        hasher.update(name.as_bytes());
106        match read_env(name) {
107            Some(value) => {
108                hasher.update(b"\0present\0");
109                hasher.update(value.to_string_lossy().as_bytes());
110            }
111            None => hasher.update(b"\0missing"),
112        }
113    }
114    let digest = hasher.finalize();
115    let mut encoded = String::with_capacity(digest.len() * 2);
116    for byte in digest {
117        let _ = write!(encoded, "{byte:02x}");
118    }
119    Some(encoded)
120}
121
122enum FileState {
123    Present(Vec<u8>),
124    Missing,
125    Unavailable,
126}
127
128fn read_bounded(path: &Path) -> FileState {
129    // Refuse directories, devices, FIFOs, sockets, and symlinks before open.
130    // Besides keeping the scope to ordinary credential/config files, this
131    // prevents a configured or replaced path from blocking on a named pipe.
132    let metadata = match path.symlink_metadata() {
133        Ok(metadata) => metadata,
134        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
135            return FileState::Missing;
136        }
137        Err(_) => return FileState::Unavailable,
138    };
139    if !metadata.file_type().is_file() || metadata.len() > MAX_SCOPE_FILE_BYTES {
140        return FileState::Unavailable;
141    }
142    let file = match File::open(path) {
143        Ok(file) => file,
144        // A race after metadata lookup fails closed, including replacement
145        // with a missing file. The bounded read below catches file growth.
146        Err(_) => return FileState::Unavailable,
147    };
148    let mut bytes = Vec::new();
149    if file
150        .take(MAX_SCOPE_FILE_BYTES + 1)
151        .read_to_end(&mut bytes)
152        .is_err()
153        || bytes.len() as u64 > MAX_SCOPE_FILE_BYTES
154    {
155        return FileState::Unavailable;
156    }
157    FileState::Present(bytes)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use tempfile::TempDir;
164
165    fn paths(td: &TempDir) -> ScopePaths {
166        ScopePaths {
167            auth: td.path().join("auth.json"),
168            config: td.path().join("config.toml"),
169        }
170    }
171
172    fn fingerprint_without_env(paths: &ScopePaths) -> Option<String> {
173        fingerprint_with(paths, |_| None)
174    }
175
176    #[test]
177    fn grok_home_override_controls_default_scope_paths() {
178        let paths = ScopePaths::defaults_with(Some(OsString::from("/custom/grok")), || {
179            panic!("a GROK_HOME override must not consult the fallback home")
180        })
181        .unwrap();
182        assert_eq!(paths.auth, PathBuf::from("/custom/grok/auth.json"));
183        assert_eq!(paths.config, PathBuf::from("/custom/grok/config.toml"));
184    }
185
186    #[test]
187    fn fingerprint_is_stable_and_contains_no_raw_identity() {
188        let td = TempDir::new().unwrap();
189        let paths = paths(&td);
190        std::fs::write(
191            &paths.auth,
192            br#"{"key":"secret","user_id":"person@example.test"}"#,
193        )
194        .unwrap();
195        std::fs::write(&paths.config, b"[grok_com_config]\n").unwrap();
196
197        let one = fingerprint_without_env(&paths).unwrap();
198        let two = fingerprint_without_env(&paths).unwrap();
199        assert_eq!(one, two);
200        assert_eq!(one.len(), 64);
201        // Independently reproduced with `sha256sum` and OpenSSL. Keep cache
202        // identities stable across hash-crate upgrades so an upgrade does not
203        // silently invalidate every user's last-known-good usage snapshot.
204        assert_eq!(
205            one,
206            "ed8be87685186d534763b874ed01adf912fddf2e929c1453c3362ca9f0d24308"
207        );
208        assert!(!one.contains("secret"));
209        assert!(!one.contains("person"));
210    }
211
212    #[test]
213    fn auth_or_config_changes_invalidate_the_scope() {
214        let td = TempDir::new().unwrap();
215        let paths = paths(&td);
216        std::fs::write(&paths.auth, b"account-a").unwrap();
217        let before = fingerprint_without_env(&paths).unwrap();
218
219        std::fs::write(&paths.auth, b"account-b").unwrap();
220        let after_login = fingerprint_without_env(&paths).unwrap();
221        assert_ne!(before, after_login);
222
223        std::fs::write(&paths.config, b"scope = 'team'").unwrap();
224        assert_ne!(after_login, fingerprint_without_env(&paths).unwrap());
225
226        let issuer_a = fingerprint_with(&paths, |name| {
227            (name == "GROK_OIDC_ISSUER").then(|| OsString::from("https://idp-a.test"))
228        })
229        .unwrap();
230        let issuer_b = fingerprint_with(&paths, |name| {
231            (name == "GROK_OIDC_ISSUER").then(|| OsString::from("https://idp-b.test"))
232        })
233        .unwrap();
234        assert_ne!(issuer_a, issuer_b);
235    }
236
237    #[test]
238    fn missing_or_oversized_auth_disables_cache_reuse() {
239        let td = TempDir::new().unwrap();
240        let paths = paths(&td);
241        assert!(fingerprint_without_env(&paths).is_none());
242
243        let file = File::create(&paths.auth).unwrap();
244        file.set_len(MAX_SCOPE_FILE_BYTES + 1).unwrap();
245        assert!(fingerprint_without_env(&paths).is_none());
246
247        std::fs::write(&paths.auth, b"valid-auth").unwrap();
248        let file = File::create(&paths.config).unwrap();
249        file.set_len(MAX_SCOPE_FILE_BYTES + 1).unwrap();
250        assert!(fingerprint_without_env(&paths).is_none());
251    }
252
253    #[cfg(unix)]
254    #[test]
255    fn symlinked_scope_files_fail_closed() {
256        use std::os::unix::fs::symlink;
257
258        let td = TempDir::new().unwrap();
259        let paths = paths(&td);
260        let target = td.path().join("real-auth.json");
261        std::fs::write(&target, b"valid-auth").unwrap();
262        symlink(&target, &paths.auth).unwrap();
263        assert!(fingerprint_without_env(&paths).is_none());
264    }
265}