Skip to main content

gemini_cli/auth/
auto_refresh.rs

1use std::path::{Path, PathBuf};
2
3use crate::auth;
4use crate::auth::output;
5
6pub fn run() -> i32 {
7    run_with_json(false)
8}
9
10pub fn run_with_json(output_json: bool) -> i32 {
11    if !is_configured() {
12        if output_json {
13            let _ = output::emit_result(
14                "auth auto-refresh",
15                output::obj(vec![
16                    ("refreshed", output::n(0)),
17                    ("skipped", output::n(0)),
18                    ("failed", output::n(0)),
19                    ("min_age_days", output::n(0)),
20                    ("targets", output::arr(Vec::new())),
21                ]),
22            );
23        }
24        return 0;
25    }
26
27    let min_days_raw =
28        std::env::var("GEMINI_AUTO_REFRESH_MIN_DAYS").unwrap_or_else(|_| "5".to_string());
29    let min_days = match min_days_raw.parse::<i64>() {
30        Ok(value) => value,
31        Err(_) => {
32            if output_json {
33                let _ = output::emit_error(
34                    "auth auto-refresh",
35                    "invalid-min-days",
36                    format!(
37                        "gemini-auto-refresh: invalid GEMINI_AUTO_REFRESH_MIN_DAYS: {}",
38                        min_days_raw
39                    ),
40                    Some(output::obj(vec![("value", output::s(min_days_raw))])),
41                );
42            } else {
43                eprintln!(
44                    "gemini-auto-refresh: invalid GEMINI_AUTO_REFRESH_MIN_DAYS: {}",
45                    min_days_raw
46                );
47            }
48            return 64;
49        }
50    };
51
52    let min_seconds = min_days.saturating_mul(86_400);
53    let now_epoch = auth::now_epoch_seconds();
54
55    let auth_file = crate::paths::resolve_auth_file();
56    if auth_file.is_some() {
57        let sync_rc = auth::sync::run_with_json(false);
58        if sync_rc != 0 {
59            if output_json {
60                let _ = output::emit_error(
61                    "auth auto-refresh",
62                    "sync-failed",
63                    "gemini-auto-refresh: failed to sync auth and secrets before refresh",
64                    None,
65                );
66            }
67            return 1;
68        }
69    }
70
71    let mut targets = Vec::new();
72    if let Some(auth_file) = auth_file.as_ref() {
73        targets.push(auth_file.clone());
74    }
75    if let Some(secret_dir) = crate::paths::resolve_secret_dir()
76        && let Ok(entries) = std::fs::read_dir(&secret_dir)
77    {
78        for entry in entries.flatten() {
79            let path = entry.path();
80            if path.extension().and_then(|s| s.to_str()) == Some("json") {
81                targets.push(path);
82            }
83        }
84    }
85
86    let mut refreshed: i64 = 0;
87    let mut skipped: i64 = 0;
88    let mut failed: i64 = 0;
89    let mut target_results: Vec<output::JsonValue> = Vec::new();
90
91    for target in targets {
92        if !target.is_file() {
93            if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
94                skipped += 1;
95                target_results.push(target_result(&target, "skipped", Some("auth-file-missing")));
96                continue;
97            }
98            if !output_json {
99                eprintln!("gemini-auto-refresh: missing file: {}", target.display());
100            }
101            failed += 1;
102            target_results.push(target_result(&target, "failed", Some("missing-file")));
103            continue;
104        }
105
106        let timestamp_path = timestamp_path(&target);
107        match should_refresh(&target, timestamp_path.as_deref(), now_epoch, min_seconds) {
108            RefreshDecision::Refresh => {
109                let rc = if auth_file.as_ref().map(|p| p == &target).unwrap_or(false) {
110                    if output_json {
111                        auth::refresh::run_silent(&[])
112                    } else {
113                        auth::refresh::run(&[])
114                    }
115                } else {
116                    let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("");
117                    if output_json {
118                        auth::refresh::run_silent(&[name.to_string()])
119                    } else {
120                        auth::refresh::run(&[name.to_string()])
121                    }
122                };
123
124                if rc == 0 {
125                    refreshed += 1;
126                    target_results.push(target_result(&target, "refreshed", None));
127                } else {
128                    failed += 1;
129                    target_results.push(target_result(
130                        &target,
131                        "failed",
132                        Some(&format!("refresh-exit-{rc}")),
133                    ));
134                }
135            }
136            RefreshDecision::Skip => {
137                skipped += 1;
138                target_results.push(target_result(&target, "skipped", Some("not-due")));
139            }
140            RefreshDecision::WarnFuture => {
141                if !output_json {
142                    eprintln!(
143                        "gemini-auto-refresh: warning: future timestamp for {}",
144                        target.display()
145                    );
146                }
147                skipped += 1;
148                target_results.push(target_result(&target, "skipped", Some("future-timestamp")));
149            }
150        }
151    }
152
153    if output_json {
154        let _ = output::emit_result(
155            "auth auto-refresh",
156            output::obj(vec![
157                ("refreshed", output::n(refreshed)),
158                ("skipped", output::n(skipped)),
159                ("failed", output::n(failed)),
160                ("min_age_days", output::n(min_days)),
161                ("targets", output::arr(target_results)),
162            ]),
163        );
164    } else {
165        println!(
166            "gemini-auto-refresh: refreshed={} skipped={} failed={} (min_age_days={})",
167            refreshed, skipped, failed, min_days
168        );
169    }
170
171    if failed > 0 {
172        return 1;
173    }
174
175    0
176}
177
178fn target_result(target: &Path, status: &str, reason: Option<&str>) -> output::JsonValue {
179    let mut fields = vec![
180        (
181            "target_file".to_string(),
182            output::s(target.display().to_string()),
183        ),
184        ("status".to_string(), output::s(status)),
185    ];
186    if let Some(reason) = reason {
187        fields.push(("reason".to_string(), output::s(reason)));
188    }
189    output::obj_dynamic(fields)
190}
191
192fn is_configured() -> bool {
193    let mut candidates = Vec::new();
194    if let Some(auth_file) = crate::paths::resolve_auth_file() {
195        candidates.push(auth_file);
196    }
197    if let Some(secret_dir) = crate::paths::resolve_secret_dir()
198        && let Ok(entries) = std::fs::read_dir(&secret_dir)
199    {
200        for entry in entries.flatten() {
201            let path = entry.path();
202            if path.extension().and_then(|s| s.to_str()) == Some("json") {
203                candidates.push(path);
204            }
205        }
206    }
207
208    candidates.iter().any(|path| path.is_file())
209}
210
211enum RefreshDecision {
212    Refresh,
213    Skip,
214    WarnFuture,
215}
216
217fn should_refresh(
218    target: &Path,
219    timestamp_path: Option<&Path>,
220    now_epoch: i64,
221    min_seconds: i64,
222) -> RefreshDecision {
223    if let Some(last_epoch) = last_refresh_epoch(target, timestamp_path) {
224        let age = now_epoch - last_epoch;
225        if age < 0 {
226            return RefreshDecision::WarnFuture;
227        }
228        if age >= min_seconds {
229            RefreshDecision::Refresh
230        } else {
231            RefreshDecision::Skip
232        }
233    } else {
234        RefreshDecision::Refresh
235    }
236}
237
238fn last_refresh_epoch(target: &Path, timestamp_path: Option<&Path>) -> Option<i64> {
239    if let Some(path) = timestamp_path
240        && let Ok(content) = std::fs::read_to_string(path)
241    {
242        let iso = auth::normalize_iso(&content);
243        if let Some(epoch) = auth::parse_rfc3339_epoch(&iso) {
244            return Some(epoch);
245        }
246    }
247
248    let iso = auth::last_refresh_from_auth_file(target).ok().flatten()?;
249    let iso = auth::normalize_iso(&iso);
250    let epoch = auth::parse_rfc3339_epoch(&iso)?;
251    if let Some(path) = timestamp_path {
252        let _ = auth::write_timestamp(path, Some(&iso));
253    }
254    Some(epoch)
255}
256
257fn timestamp_path(target: &Path) -> Option<PathBuf> {
258    let cache_dir = crate::paths::resolve_secret_cache_dir()?;
259    let name = target
260        .file_name()
261        .and_then(|name| name.to_str())
262        .unwrap_or("auth.json");
263    Some(cache_dir.join(format!("{name}.timestamp")))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::{
269        RefreshDecision, is_configured, last_refresh_epoch, run_with_json, should_refresh,
270        timestamp_path,
271    };
272    use crate::auth;
273    use std::ffi::{OsStr, OsString};
274    use std::fs;
275    use std::path::{Path, PathBuf};
276    use std::time::{SystemTime, UNIX_EPOCH};
277
278    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
279        crate::auth::test_env_lock()
280    }
281
282    struct EnvGuard {
283        key: &'static str,
284        old: Option<OsString>,
285    }
286
287    impl EnvGuard {
288        fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
289            let old = std::env::var_os(key);
290            // SAFETY: tests mutate env in guarded scope.
291            unsafe { std::env::set_var(key, value) };
292            Self { key, old }
293        }
294    }
295
296    impl Drop for EnvGuard {
297        fn drop(&mut self) {
298            if let Some(value) = self.old.take() {
299                // SAFETY: tests restore env in guarded scope.
300                unsafe { std::env::set_var(self.key, value) };
301            } else {
302                // SAFETY: tests restore env in guarded scope.
303                unsafe { std::env::remove_var(self.key) };
304            }
305        }
306    }
307
308    struct TestDir {
309        path: PathBuf,
310    }
311
312    impl TestDir {
313        fn new(label: &str) -> Self {
314            let nanos = SystemTime::now()
315                .duration_since(UNIX_EPOCH)
316                .map(|duration| duration.as_nanos())
317                .unwrap_or(0);
318            let path = std::env::temp_dir().join(format!(
319                "nils-gemini-auto-refresh-{label}-{}-{nanos}",
320                std::process::id()
321            ));
322            let _ = fs::remove_dir_all(&path);
323            fs::create_dir_all(&path).expect("temp dir");
324            Self { path }
325        }
326
327        fn join(&self, child: &str) -> PathBuf {
328            self.path.join(child)
329        }
330    }
331
332    impl Drop for TestDir {
333        fn drop(&mut self) {
334            let _ = fs::remove_dir_all(&self.path);
335        }
336    }
337
338    fn write_auth(target: &Path, last_refresh: &str) {
339        fs::write(target, format!("{{\"last_refresh\":\"{last_refresh}\"}}")).expect("write auth");
340    }
341
342    #[test]
343    fn run_with_json_returns_zero_when_not_configured() {
344        let _lock = env_lock();
345        let dir = TestDir::new("not-configured");
346        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
347        let _secret = EnvGuard::set("GEMINI_SECRET_DIR", dir.join("missing-secrets"));
348        assert_eq!(run_with_json(true), 0);
349        assert_eq!(run_with_json(false), 0);
350    }
351
352    #[test]
353    fn run_with_json_invalid_min_days_returns_64() {
354        let _lock = env_lock();
355        let dir = TestDir::new("invalid-min-days");
356        let secrets = dir.join("secrets");
357        fs::create_dir_all(&secrets).expect("secrets");
358        write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
359
360        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
361        let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
362        let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "bogus");
363
364        assert_eq!(run_with_json(true), 64);
365        assert_eq!(run_with_json(false), 64);
366    }
367
368    #[test]
369    fn should_refresh_covers_refresh_skip_and_future() {
370        let dir = TestDir::new("should-refresh");
371        let auth_file = dir.join("auth.json");
372        write_auth(&auth_file, "2026-01-01T00:00:00Z");
373        let last_epoch = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
374
375        assert!(matches!(
376            should_refresh(&auth_file, None, last_epoch + 86_400, 86_400),
377            RefreshDecision::Refresh
378        ));
379        assert!(matches!(
380            should_refresh(&auth_file, None, last_epoch + 100, 86_400),
381            RefreshDecision::Skip
382        ));
383        assert!(matches!(
384            should_refresh(&auth_file, None, last_epoch - 1, 86_400),
385            RefreshDecision::WarnFuture
386        ));
387    }
388
389    #[test]
390    fn last_refresh_epoch_prefers_timestamp_and_backfills_when_needed() {
391        let _lock = env_lock();
392        let dir = TestDir::new("last-refresh");
393        let auth_file = dir.join("auth.json");
394        let cache_dir = dir.join("cache");
395        fs::create_dir_all(&cache_dir).expect("cache dir");
396        let ts_file = cache_dir.join("auth.json.timestamp");
397        write_auth(&auth_file, "2026-01-01T00:00:00Z");
398
399        fs::write(&ts_file, "2026-01-02T00:00:00Z").expect("write timestamp");
400        let from_timestamp =
401            last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from timestamp");
402        let expected_from_ts = auth::parse_rfc3339_epoch("2026-01-02T00:00:00Z").expect("epoch");
403        assert_eq!(from_timestamp, expected_from_ts);
404
405        fs::write(&ts_file, "not-an-iso").expect("write bad timestamp");
406        let from_auth = last_refresh_epoch(&auth_file, Some(&ts_file)).expect("epoch from auth");
407        let expected_from_auth = auth::parse_rfc3339_epoch("2026-01-01T00:00:00Z").expect("epoch");
408        assert_eq!(from_auth, expected_from_auth);
409        assert!(
410            fs::read_to_string(&ts_file)
411                .expect("read backfilled")
412                .contains("2026-01-01")
413        );
414    }
415
416    #[test]
417    fn is_configured_detects_auth_or_secret_files() {
418        let _lock = env_lock();
419        let dir = TestDir::new("is-configured");
420        let auth_file = dir.join("auth.json");
421        let secrets = dir.join("secrets");
422        fs::create_dir_all(&secrets).expect("secrets");
423
424        let missing_auth = dir.join("missing-auth.json");
425        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &missing_auth);
426        let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
427        assert!(!is_configured());
428
429        write_auth(&auth_file, "2026-01-01T00:00:00Z");
430        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &auth_file);
431        assert!(is_configured());
432
433        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", &missing_auth);
434        write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
435        assert!(is_configured());
436    }
437
438    #[test]
439    fn timestamp_path_uses_secret_cache_dir() {
440        let _lock = env_lock();
441        let dir = TestDir::new("timestamp-path");
442        let cache_root = dir.join("cache");
443        fs::create_dir_all(&cache_root).expect("cache root");
444        let _cache = EnvGuard::set("GEMINI_SECRET_CACHE_DIR", &cache_root);
445        let path = timestamp_path(Path::new("/tmp/alpha.json")).expect("timestamp path");
446        assert_eq!(path, cache_root.join("alpha.json.timestamp"));
447    }
448
449    #[test]
450    fn run_with_json_reports_failed_for_missing_file_like_target() {
451        let _lock = env_lock();
452        let dir = TestDir::new("missing-target");
453        let secrets = dir.join("secrets");
454        fs::create_dir_all(&secrets).expect("secrets");
455
456        write_auth(&secrets.join("good.json"), "2100-01-01T00:00:00Z");
457        fs::create_dir_all(secrets.join("broken.json")).expect("broken json dir");
458
459        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
460        let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
461        let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "5");
462        assert_eq!(run_with_json(false), 1);
463    }
464
465    #[test]
466    fn run_with_json_emits_summary_when_targets_are_skipped() {
467        let _lock = env_lock();
468        let dir = TestDir::new("json-summary");
469        let secrets = dir.join("secrets");
470        fs::create_dir_all(&secrets).expect("secrets");
471        write_auth(&secrets.join("alpha.json"), "2026-01-01T00:00:00Z");
472
473        let _auth = EnvGuard::set("GEMINI_AUTH_FILE", dir.join("missing-auth.json"));
474        let _secret = EnvGuard::set("GEMINI_SECRET_DIR", &secrets);
475        let _min_days = EnvGuard::set("GEMINI_AUTO_REFRESH_MIN_DAYS", "99999");
476        assert_eq!(run_with_json(true), 0);
477    }
478}