ai_usagebar/supergrok/
scope.rs1use 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
75pub 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 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 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 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}