use super::{Account, AuthTool, Snapshot};
use crate::paths::Paths;
use crate::secret::Secret;
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub struct Gemini;
pub(crate) fn jwt_claim(id_token: Option<&str>, claim: &str) -> Option<String> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let payload = id_token?.split('.').nth(1)?;
let json = URL_SAFE_NO_PAD.decode(payload).ok()?;
let v: Value = serde_json::from_slice(&json).ok()?;
v[claim].as_str().map(|s| s.to_string())
}
#[derive(serde::Serialize, serde::Deserialize)]
struct GeminiWal {
oauth_path: String,
oauth_prior_hex: Option<String>,
accounts_path: String,
accounts_prior_hex: Option<String>,
}
fn gemini_wal_path(paths: &Paths) -> std::path::PathBuf {
paths.store_dir().join("apply-gemini.wal")
}
fn write_gemini_wal(paths: &Paths, wal: &GeminiWal) -> Result<()> {
let p = gemini_wal_path(paths);
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).ok();
}
let bytes = serde_json::to_vec(wal).context("serialize gemini WAL")?;
crate::atomic::write_secret(&p, &bytes)
}
fn remove_gemini_wal(paths: &Paths) {
let _ = std::fs::remove_file(gemini_wal_path(paths));
}
fn restore_gemini_file(path: &str, prior_hex: &Option<String>) -> bool {
let p = std::path::PathBuf::from(path);
match prior_hex {
Some(hex) => match crate::adapters::claude::from_hex(hex) {
Some(b) => crate::atomic::write_secret(&p, &b).is_ok(),
None => false,
},
None => std::fs::remove_file(&p).is_ok() || !p.exists(),
}
}
pub(crate) fn recover_interrupted_gemini_apply(paths: &Paths) {
let p = gemini_wal_path(paths);
let Ok(bytes) = std::fs::read(&p) else {
return;
};
let Ok(wal) = serde_json::from_slice::<GeminiWal>(&bytes) else {
return; };
let mut ok = restore_gemini_file(&wal.oauth_path, &wal.oauth_prior_hex);
ok &= restore_gemini_file(&wal.accounts_path, &wal.accounts_prior_hex);
if ok {
remove_gemini_wal(paths);
}
}
impl AuthTool for Gemini {
fn name(&self) -> &'static str {
"gemini"
}
fn present(&self, paths: &Paths) -> bool {
paths.gemini_oauth().exists()
}
fn capture(&self, paths: &Paths) -> Result<Snapshot> {
recover_interrupted_gemini_apply(paths);
let oauth_path = paths.gemini_oauth();
if !oauth_path.exists() {
bail!("not logged in to Gemini CLI (no {})", oauth_path.display());
}
let oauth = crate::atomic::read_regular(&oauth_path)?;
serde_json::from_slice::<Value>(&oauth).context("oauth_creds.json is not valid JSON")?;
let accounts_path = paths.gemini_accounts();
let accounts = if accounts_path.exists() {
let bytes = crate::atomic::read_regular(&accounts_path)?;
serde_json::from_slice::<Value>(&bytes)
.context("google_accounts.json is not valid JSON")?;
bytes
} else {
b"{}".to_vec()
};
Ok(Snapshot {
tool: "gemini",
blobs: vec![
("oauth".into(), Secret::new(oauth)),
("accounts".into(), Secret::new(accounts)),
],
})
}
fn apply(&self, paths: &Paths, snap: &Snapshot) -> Result<()> {
recover_interrupted_gemini_apply(paths);
let oauth = snap.part("oauth").context("snapshot missing oauth")?;
let accounts = snap.part("accounts").context("snapshot missing accounts")?;
serde_json::from_slice::<Value>(oauth.expose())
.context("saved oauth_creds are not valid JSON; refusing to apply")?;
serde_json::from_slice::<Value>(accounts.expose())
.context("saved google_accounts are not valid JSON; refusing to apply")?;
let oauth_path = paths.gemini_oauth();
let accounts_path = paths.gemini_accounts();
let prev_oauth = if oauth_path.exists() {
crate::atomic::read_regular(&oauth_path).ok()
} else {
None
};
let prev_accounts = if accounts_path.exists() {
crate::atomic::read_regular(&accounts_path).ok()
} else {
None
};
write_gemini_wal(
paths,
&GeminiWal {
oauth_path: oauth_path.to_string_lossy().into_owned(),
oauth_prior_hex: prev_oauth.as_deref().map(crate::adapters::claude::to_hex),
accounts_path: accounts_path.to_string_lossy().into_owned(),
accounts_prior_hex: prev_accounts
.as_deref()
.map(crate::adapters::claude::to_hex),
},
)?;
crate::atomic::write_secret(&oauth_path, oauth.expose())?;
if let Err(e) = crate::atomic::write_secret(&accounts_path, accounts.expose()) {
let (msg, rolled_back) = match &prev_oauth {
Some(prev) => match crate::atomic::write_secret(&oauth_path, prev) {
Ok(()) => ("apply aborted; oauth_creds rolled back", true),
Err(_) => (
"apply aborted and the rollback FAILED - the login may be \
half-swapped; run `swapdex restore --tool gemini` once the \
underlying problem (e.g. disk space) is fixed",
false,
),
},
None => match std::fs::remove_file(&oauth_path) {
Ok(()) => (
"apply aborted; the just-written oauth_creds were removed",
true,
),
Err(_) => (
"apply aborted and cleanup FAILED - oauth_creds was written without \
google_accounts; run `swapdex restore --tool gemini` once the \
underlying problem is fixed",
false,
),
},
};
if rolled_back {
remove_gemini_wal(paths);
}
return Err(e.context(msg));
}
remove_gemini_wal(paths);
Ok(())
}
fn identity(&self, paths: &Paths) -> Result<Option<Account>> {
let oauth_path = paths.gemini_oauth();
if !oauth_path.exists() {
return Ok(None);
}
let oauth: Value = serde_json::from_slice(&crate::atomic::read_regular(&oauth_path)?)
.context("parse oauth_creds.json")?;
let id_token = oauth["id_token"].as_str();
let sub = jwt_claim(id_token, "sub").unwrap_or_default();
let email = std::fs::read(paths.gemini_accounts())
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["active"].as_str().map(|s| s.to_string()))
.or_else(|| jwt_claim(id_token, "email"));
Ok(Some(Account {
tool: "gemini",
account_id: sub,
display: email.clone().unwrap_or_else(|| "Google account".into()),
email,
tier: None,
expires_at: oauth["expiry_date"].as_i64(),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seed(p: &Paths, oauth: &[u8], accounts: &[u8]) {
std::fs::create_dir_all(p.gemini_oauth().parent().unwrap()).unwrap();
std::fs::write(p.gemini_oauth(), oauth).unwrap();
std::fs::write(p.gemini_accounts(), accounts).unwrap();
}
#[test]
fn recover_rolls_back_a_crashed_gemini_apply_to_prior() {
let d = tempfile::tempdir().unwrap();
let p = Paths::rooted(d.path());
let oauth_b = br#"{"refresh_token":"RT-B"}"#;
let accounts_b = br#"{"active":"b@x.com"}"#;
seed(&p, oauth_b, accounts_b);
write_gemini_wal(
&p,
&GeminiWal {
oauth_path: p.gemini_oauth().to_string_lossy().into_owned(),
oauth_prior_hex: Some(crate::adapters::claude::to_hex(oauth_b)),
accounts_path: p.gemini_accounts().to_string_lossy().into_owned(),
accounts_prior_hex: Some(crate::adapters::claude::to_hex(accounts_b)),
},
)
.unwrap();
std::fs::write(p.gemini_oauth(), br#"{"refresh_token":"RT-A"}"#).unwrap();
recover_interrupted_gemini_apply(&p);
assert_eq!(
std::fs::read(p.gemini_oauth()).unwrap(),
oauth_b,
"oauth rolled back to B"
);
assert_eq!(
std::fs::read(p.gemini_accounts()).unwrap(),
accounts_b,
"accounts stays B"
);
assert!(
!gemini_wal_path(&p).exists(),
"WAL removed after a successful recovery"
);
}
#[test]
fn gemini_apply_leaves_no_wal_on_success() {
let d = tempfile::tempdir().unwrap();
let p = Paths::rooted(d.path());
seed(
&p,
br#"{"refresh_token":"RT-B"}"#,
br#"{"active":"b@x.com"}"#,
);
let snap = Gemini.capture(&p).unwrap();
Gemini.apply(&p, &snap).unwrap();
assert!(
!gemini_wal_path(&p).exists(),
"WAL retired once the apply is consistent"
);
}
}