use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::cache::home_dir;
use crate::error::Result;
const MAX_SCOPE_FILE_BYTES: u64 = 2 * 1024 * 1024;
const SCOPE_ENV: [&str; 8] = [
"GROK_OIDC_ISSUER",
"GROK_OIDC_CLIENT_ID",
"GROK_AUTH_PROVIDER_COMMAND",
"GROK_AUTH_TOKEN_TTL",
"GROK_CLI_CHAT_PROXY_BASE_URL",
"GROK_API_KEY",
"XAI_API_KEY",
"GROK_HOME",
];
#[derive(Debug, Clone)]
pub struct ScopePaths {
pub auth: PathBuf,
pub config: PathBuf,
}
impl ScopePaths {
pub fn defaults() -> Result<Self> {
Self::defaults_with(std::env::var_os("GROK_HOME"), home_dir)
}
fn defaults_with<F>(grok_home: Option<OsString>, fallback_home: F) -> Result<Self>
where
F: FnOnce() -> Result<PathBuf>,
{
let grok_dir = match grok_home.filter(|value| !value.is_empty()) {
Some(path) => PathBuf::from(path),
None => fallback_home()?.join(".grok"),
};
Ok(Self {
auth: grok_dir.join("auth.json"),
config: grok_dir.join("config.toml"),
})
}
pub fn with_overrides(auth: Option<&Path>, config: Option<&Path>) -> Result<Self> {
if let (Some(auth), Some(config)) = (auth, config) {
return Ok(Self {
auth: auth.to_path_buf(),
config: config.to_path_buf(),
});
}
let mut paths = Self::defaults()?;
if let Some(auth) = auth {
paths.auth = auth.to_path_buf();
}
if let Some(config) = config {
paths.config = config.to_path_buf();
}
Ok(paths)
}
}
pub fn fingerprint(paths: &ScopePaths) -> Option<String> {
fingerprint_with(paths, |name| std::env::var_os(name))
}
fn fingerprint_with<F>(paths: &ScopePaths, read_env: F) -> Option<String>
where
F: Fn(&str) -> Option<OsString>,
{
let FileState::Present(auth) = read_bounded(&paths.auth) else {
return None;
};
let config = read_bounded(&paths.config);
let mut hasher = Sha256::new();
hasher.update(b"ai-usagebar-supergrok-scope-v2\0auth\0");
hasher.update(&auth);
hasher.update(b"\0config\0");
match config {
FileState::Present(bytes) => {
hasher.update(b"present\0");
hasher.update(&bytes);
}
FileState::Missing => hasher.update(b"missing"),
FileState::Unavailable => return None,
}
for name in SCOPE_ENV {
hasher.update(b"\0env\0");
hasher.update(name.as_bytes());
match read_env(name) {
Some(value) => {
hasher.update(b"\0present\0");
hasher.update(value.to_string_lossy().as_bytes());
}
None => hasher.update(b"\0missing"),
}
}
let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(encoded, "{byte:02x}");
}
Some(encoded)
}
enum FileState {
Present(Vec<u8>),
Missing,
Unavailable,
}
fn read_bounded(path: &Path) -> FileState {
let metadata = match path.symlink_metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return FileState::Missing;
}
Err(_) => return FileState::Unavailable,
};
if !metadata.file_type().is_file() || metadata.len() > MAX_SCOPE_FILE_BYTES {
return FileState::Unavailable;
}
let file = match File::open(path) {
Ok(file) => file,
Err(_) => return FileState::Unavailable,
};
let mut bytes = Vec::new();
if file
.take(MAX_SCOPE_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.is_err()
|| bytes.len() as u64 > MAX_SCOPE_FILE_BYTES
{
return FileState::Unavailable;
}
FileState::Present(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn paths(td: &TempDir) -> ScopePaths {
ScopePaths {
auth: td.path().join("auth.json"),
config: td.path().join("config.toml"),
}
}
fn fingerprint_without_env(paths: &ScopePaths) -> Option<String> {
fingerprint_with(paths, |_| None)
}
#[test]
fn grok_home_override_controls_default_scope_paths() {
let paths = ScopePaths::defaults_with(Some(OsString::from("/custom/grok")), || {
panic!("a GROK_HOME override must not consult the fallback home")
})
.unwrap();
assert_eq!(paths.auth, PathBuf::from("/custom/grok/auth.json"));
assert_eq!(paths.config, PathBuf::from("/custom/grok/config.toml"));
}
#[test]
fn fingerprint_is_stable_and_contains_no_raw_identity() {
let td = TempDir::new().unwrap();
let paths = paths(&td);
std::fs::write(
&paths.auth,
br#"{"key":"secret","user_id":"person@example.test"}"#,
)
.unwrap();
std::fs::write(&paths.config, b"[grok_com_config]\n").unwrap();
let one = fingerprint_without_env(&paths).unwrap();
let two = fingerprint_without_env(&paths).unwrap();
assert_eq!(one, two);
assert_eq!(one.len(), 64);
assert_eq!(
one,
"ed8be87685186d534763b874ed01adf912fddf2e929c1453c3362ca9f0d24308"
);
assert!(!one.contains("secret"));
assert!(!one.contains("person"));
}
#[test]
fn auth_or_config_changes_invalidate_the_scope() {
let td = TempDir::new().unwrap();
let paths = paths(&td);
std::fs::write(&paths.auth, b"account-a").unwrap();
let before = fingerprint_without_env(&paths).unwrap();
std::fs::write(&paths.auth, b"account-b").unwrap();
let after_login = fingerprint_without_env(&paths).unwrap();
assert_ne!(before, after_login);
std::fs::write(&paths.config, b"scope = 'team'").unwrap();
assert_ne!(after_login, fingerprint_without_env(&paths).unwrap());
let issuer_a = fingerprint_with(&paths, |name| {
(name == "GROK_OIDC_ISSUER").then(|| OsString::from("https://idp-a.test"))
})
.unwrap();
let issuer_b = fingerprint_with(&paths, |name| {
(name == "GROK_OIDC_ISSUER").then(|| OsString::from("https://idp-b.test"))
})
.unwrap();
assert_ne!(issuer_a, issuer_b);
}
#[test]
fn missing_or_oversized_auth_disables_cache_reuse() {
let td = TempDir::new().unwrap();
let paths = paths(&td);
assert!(fingerprint_without_env(&paths).is_none());
let file = File::create(&paths.auth).unwrap();
file.set_len(MAX_SCOPE_FILE_BYTES + 1).unwrap();
assert!(fingerprint_without_env(&paths).is_none());
std::fs::write(&paths.auth, b"valid-auth").unwrap();
let file = File::create(&paths.config).unwrap();
file.set_len(MAX_SCOPE_FILE_BYTES + 1).unwrap();
assert!(fingerprint_without_env(&paths).is_none());
}
#[cfg(unix)]
#[test]
fn symlinked_scope_files_fail_closed() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let paths = paths(&td);
let target = td.path().join("real-auth.json");
std::fs::write(&target, b"valid-auth").unwrap();
symlink(&target, &paths.auth).unwrap();
assert!(fingerprint_without_env(&paths).is_none());
}
}