Skip to main content

gemini_cli/auth/
mod.rs

1pub mod auto_refresh;
2pub mod current;
3pub mod login;
4pub mod output;
5pub mod refresh;
6pub mod remove;
7pub mod save;
8pub mod sync;
9pub mod use_secret;
10
11use std::fs::{self, OpenOptions};
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16#[cfg(unix)]
17use std::os::unix::fs::PermissionsExt;
18
19pub(crate) const SECRET_FILE_MODE: u32 = 0o600;
20
21#[cfg(test)]
22pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
23    use std::sync::{Mutex, OnceLock};
24
25    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
26    match LOCK.get_or_init(|| Mutex::new(())).lock() {
27        Ok(guard) => guard,
28        Err(poisoned) => poisoned.into_inner(),
29    }
30}
31
32pub fn identity_from_auth_file(path: &Path) -> io::Result<Option<String>> {
33    crate::runtime::auth::identity_from_auth_file(path).map_err(core_error_to_io)
34}
35
36pub fn email_from_auth_file(path: &Path) -> io::Result<Option<String>> {
37    crate::runtime::auth::email_from_auth_file(path).map_err(core_error_to_io)
38}
39
40pub fn account_id_from_auth_file(path: &Path) -> io::Result<Option<String>> {
41    crate::runtime::auth::account_id_from_auth_file(path).map_err(core_error_to_io)
42}
43
44pub fn last_refresh_from_auth_file(path: &Path) -> io::Result<Option<String>> {
45    crate::runtime::auth::last_refresh_from_auth_file(path).map_err(core_error_to_io)
46}
47
48pub fn identity_key_from_auth_file(path: &Path) -> io::Result<Option<String>> {
49    crate::runtime::auth::identity_key_from_auth_file(path).map_err(core_error_to_io)
50}
51
52pub(crate) fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> {
53    if let Some(parent) = path.parent() {
54        fs::create_dir_all(parent)?;
55    }
56
57    let mut attempt = 0u32;
58    loop {
59        let tmp_path = temp_path(path, attempt);
60        match OpenOptions::new()
61            .write(true)
62            .create_new(true)
63            .open(&tmp_path)
64        {
65            Ok(mut file) => {
66                file.write_all(contents)?;
67                let _ = file.flush();
68
69                set_permissions(&tmp_path, mode)?;
70                drop(file);
71
72                fs::rename(&tmp_path, path)?;
73                set_permissions(path, mode)?;
74                return Ok(());
75            }
76            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
77                attempt += 1;
78                if attempt > 10 {
79                    return Err(err);
80                }
81            }
82            Err(err) => return Err(err),
83        }
84    }
85}
86
87pub(crate) fn write_timestamp(path: &Path, iso: Option<&str>) -> io::Result<()> {
88    if let Some(parent) = path.parent() {
89        fs::create_dir_all(parent)?;
90    }
91
92    if let Some(raw) = iso {
93        let trimmed = strip_newlines(raw);
94        if !trimmed.is_empty() {
95            fs::write(path, trimmed)?;
96            return Ok(());
97        }
98    }
99
100    let _ = fs::remove_file(path);
101    Ok(())
102}
103
104pub(crate) fn strip_newlines(raw: &str) -> String {
105    raw.split(&['\n', '\r'][..])
106        .next()
107        .unwrap_or("")
108        .to_string()
109}
110
111pub(crate) fn normalize_iso(raw: &str) -> String {
112    let mut trimmed = strip_newlines(raw);
113    if let Some(dot) = trimmed.find('.')
114        && trimmed.ends_with('Z')
115    {
116        trimmed.truncate(dot);
117        trimmed.push('Z');
118    }
119    trimmed
120}
121
122pub(crate) fn parse_rfc3339_epoch(raw: &str) -> Option<i64> {
123    let normalized = normalize_iso(raw);
124    let (datetime, offset_seconds) = if normalized.ends_with('Z') {
125        (&normalized[..normalized.len().saturating_sub(1)], 0i64)
126    } else {
127        if normalized.len() < 6 {
128            return None;
129        }
130        let tail_index = normalized.len() - 6;
131        let sign = normalized.as_bytes().get(tail_index).copied()? as char;
132        if sign != '+' && sign != '-' {
133            return None;
134        }
135        if normalized.as_bytes().get(tail_index + 3).copied()? as char != ':' {
136            return None;
137        }
138        let hours = parse_u32(&normalized[tail_index + 1..tail_index + 3])? as i64;
139        let minutes = parse_u32(&normalized[tail_index + 4..])? as i64;
140        let mut offset = hours * 3600 + minutes * 60;
141        if sign == '-' {
142            offset = -offset;
143        }
144        (&normalized[..tail_index], offset)
145    };
146
147    if datetime.len() != 19 {
148        return None;
149    }
150    if datetime.as_bytes().get(4).copied()? as char != '-'
151        || datetime.as_bytes().get(7).copied()? as char != '-'
152        || datetime.as_bytes().get(10).copied()? as char != 'T'
153        || datetime.as_bytes().get(13).copied()? as char != ':'
154        || datetime.as_bytes().get(16).copied()? as char != ':'
155    {
156        return None;
157    }
158
159    let year = parse_i64(&datetime[0..4])?;
160    let month = parse_u32(&datetime[5..7])? as i64;
161    let day = parse_u32(&datetime[8..10])? as i64;
162    let hour = parse_u32(&datetime[11..13])? as i64;
163    let minute = parse_u32(&datetime[14..16])? as i64;
164    let second = parse_u32(&datetime[17..19])? as i64;
165
166    if !(1..=12).contains(&month)
167        || !(1..=31).contains(&day)
168        || hour > 23
169        || minute > 59
170        || second > 60
171    {
172        return None;
173    }
174
175    let days = days_from_civil(year, month, day);
176    let local_epoch = days * 86_400 + hour * 3_600 + minute * 60 + second;
177    Some(local_epoch - offset_seconds)
178}
179
180pub(crate) fn now_epoch_seconds() -> i64 {
181    SystemTime::now()
182        .duration_since(UNIX_EPOCH)
183        .map(|duration| duration.as_secs() as i64)
184        .unwrap_or(0)
185}
186
187pub(crate) fn now_utc_iso() -> String {
188    epoch_to_utc_iso(now_epoch_seconds())
189}
190
191pub(crate) fn temp_file_path(prefix: &str) -> PathBuf {
192    let mut path = std::env::temp_dir();
193    let pid = std::process::id();
194    let nanos = SystemTime::now()
195        .duration_since(UNIX_EPOCH)
196        .map(|duration| duration.as_nanos())
197        .unwrap_or(0);
198    path.push(format!("{prefix}-{pid}-{nanos}.json"));
199    path
200}
201
202fn core_error_to_io(err: crate::runtime::CoreError) -> io::Error {
203    io::Error::other(err.to_string())
204}
205
206#[cfg(unix)]
207fn set_permissions(path: &Path, mode: u32) -> io::Result<()> {
208    let permissions = fs::Permissions::from_mode(mode);
209    fs::set_permissions(path, permissions)
210}
211
212#[cfg(not(unix))]
213fn set_permissions(_path: &Path, _mode: u32) -> io::Result<()> {
214    Ok(())
215}
216
217fn temp_path(path: &Path, attempt: u32) -> PathBuf {
218    let filename = path
219        .file_name()
220        .and_then(|name| name.to_str())
221        .unwrap_or("tmp");
222    let pid = std::process::id();
223    let nanos = SystemTime::now()
224        .duration_since(UNIX_EPOCH)
225        .map(|duration| duration.as_nanos())
226        .unwrap_or(0);
227    path.with_file_name(format!(".{filename}.tmp-{pid}-{nanos}-{attempt}"))
228}
229
230fn parse_u32(raw: &str) -> Option<u32> {
231    raw.parse::<u32>().ok()
232}
233
234fn parse_i64(raw: &str) -> Option<i64> {
235    raw.parse::<i64>().ok()
236}
237
238fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
239    let adjusted_year = year - i64::from(month <= 2);
240    let era = if adjusted_year >= 0 {
241        adjusted_year / 400
242    } else {
243        (adjusted_year - 399) / 400
244    };
245    let year_of_era = adjusted_year - era * 400;
246    let month_prime = month + if month > 2 { -3 } else { 9 };
247    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
248    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
249    era * 146_097 + day_of_era - 719_468
250}
251
252fn epoch_to_utc_iso(epoch: i64) -> String {
253    let days = epoch.div_euclid(86_400);
254    let seconds_of_day = epoch.rem_euclid(86_400);
255
256    let (year, month, day) = civil_from_days(days);
257    let hour = seconds_of_day / 3_600;
258    let minute = (seconds_of_day % 3_600) / 60;
259    let second = seconds_of_day % 60;
260
261    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
262}
263
264fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
265    let z = days_since_epoch + 719_468;
266    let era = if z >= 0 {
267        z / 146_097
268    } else {
269        (z - 146_096) / 146_097
270    };
271    let day_of_era = z - era * 146_097;
272    let year_of_era =
273        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
274    let year = year_of_era + era * 400;
275    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
276    let month_prime = (5 * day_of_year + 2) / 153;
277    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
278    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
279    let full_year = year + i64::from(month <= 2);
280
281    (full_year, month, day)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{normalize_iso, parse_rfc3339_epoch};
287
288    #[test]
289    fn normalize_iso_removes_fractional_seconds() {
290        assert_eq!(
291            normalize_iso("2025-01-20T12:34:56.789Z"),
292            "2025-01-20T12:34:56Z"
293        );
294    }
295
296    #[test]
297    fn parse_rfc3339_epoch_supports_zulu_and_offsets() {
298        assert_eq!(parse_rfc3339_epoch("1970-01-01T00:00:00Z"), Some(0));
299        assert_eq!(parse_rfc3339_epoch("1970-01-01T01:00:00+01:00"), Some(0));
300    }
301}