Skip to main content

gemini_cli/auth/
save.rs

1use std::io::{self, IsTerminal, Write};
2use std::path::{Path, PathBuf};
3
4use crate::auth;
5use crate::auth::output;
6
7pub fn run(target: &str, yes: bool) -> i32 {
8    run_with_json(target, yes, false)
9}
10
11pub fn run_with_json(target: &str, yes: bool, output_json: bool) -> i32 {
12    if target.is_empty() {
13        return usage_error(
14            output_json,
15            "gemini-save: usage: gemini-save [--yes] <secret.json>",
16        );
17    }
18
19    if is_invalid_target(target) {
20        if output_json {
21            let _ = output::emit_error(
22                "auth save",
23                "invalid-secret-file-name",
24                format!("gemini-save: invalid secret file name: {target}"),
25                Some(output::obj(vec![("target", output::s(target))])),
26            );
27        } else {
28            eprintln!("gemini-save: invalid secret file name: {target}");
29        }
30        return 64;
31    }
32
33    let secret_dir = match resolve_secret_dir() {
34        Some(path) => path,
35        None => {
36            if output_json {
37                let _ = output::emit_error(
38                    "auth save",
39                    "secret-dir-not-configured",
40                    "gemini-save: secret directory is not configured",
41                    None,
42                );
43            } else {
44                eprintln!("gemini-save: secret directory is not configured");
45            }
46            return 1;
47        }
48    };
49
50    if !secret_dir.is_dir() {
51        if output_json {
52            let _ = output::emit_error(
53                "auth save",
54                "secret-dir-not-found",
55                format!(
56                    "gemini-save: secret directory not found: {}",
57                    secret_dir.display()
58                ),
59                Some(output::obj(vec![(
60                    "secret_dir",
61                    output::s(secret_dir.display().to_string()),
62                )])),
63            );
64        } else {
65            eprintln!(
66                "gemini-save: secret directory not found: {}",
67                secret_dir.display()
68            );
69        }
70        return 1;
71    }
72
73    let auth_file = match crate::paths::resolve_auth_file() {
74        Some(path) => path,
75        None => {
76            if output_json {
77                let _ = output::emit_error(
78                    "auth save",
79                    "auth-file-not-configured",
80                    "gemini-save: GEMINI_AUTH_FILE is not configured",
81                    None,
82                );
83            } else {
84                eprintln!("gemini-save: GEMINI_AUTH_FILE is not configured");
85            }
86            return 1;
87        }
88    };
89
90    if !auth_file.is_file() {
91        if output_json {
92            let _ = output::emit_error(
93                "auth save",
94                "auth-file-not-found",
95                format!("gemini-save: auth file not found: {}", auth_file.display()),
96                Some(output::obj(vec![(
97                    "auth_file",
98                    output::s(auth_file.display().to_string()),
99                )])),
100            );
101        } else {
102            eprintln!("gemini-save: auth file not found: {}", auth_file.display());
103        }
104        return 1;
105    }
106
107    let target_file = secret_dir.join(target);
108    let mut overwritten = false;
109    if target_file.exists() {
110        if yes {
111            overwritten = true;
112        } else if output_json {
113            let _ = output::emit_error(
114                "auth save",
115                "overwrite-confirmation-required",
116                format!(
117                    "gemini-save: {} exists; rerun with --yes to overwrite",
118                    target_file.display()
119                ),
120                Some(output::obj(vec![
121                    ("target_file", output::s(target_file.display().to_string())),
122                    ("overwritten", output::b(false)),
123                ])),
124            );
125            return 1;
126        } else if !interactive_io_available() {
127            eprintln!(
128                "gemini-save: {} exists; rerun with --yes to overwrite",
129                target_file.display()
130            );
131            return 1;
132        } else {
133            match confirm_overwrite(&target_file) {
134                Ok(true) => {
135                    overwritten = true;
136                }
137                Ok(false) => {
138                    eprintln!(
139                        "gemini-save: overwrite declined for {}",
140                        target_file.display()
141                    );
142                    return 1;
143                }
144                Err(_) => return 1,
145            }
146        }
147    }
148
149    let content = match std::fs::read(&auth_file) {
150        Ok(content) => content,
151        Err(_) => {
152            if output_json {
153                let _ = output::emit_error(
154                    "auth save",
155                    "auth-file-read-failed",
156                    format!(
157                        "gemini-save: failed to read auth file: {}",
158                        auth_file.display()
159                    ),
160                    Some(output::obj(vec![(
161                        "auth_file",
162                        output::s(auth_file.display().to_string()),
163                    )])),
164                );
165            } else {
166                eprintln!(
167                    "gemini-save: failed to read auth file: {}",
168                    auth_file.display()
169                );
170            }
171            return 1;
172        }
173    };
174
175    if let Err(err) = auth::write_atomic(&target_file, &content, auth::SECRET_FILE_MODE) {
176        if output_json {
177            let _ = output::emit_error(
178                "auth save",
179                "save-write-failed",
180                format!(
181                    "gemini-save: failed to write target file {}",
182                    target_file.display()
183                ),
184                Some(output::obj(vec![
185                    ("target_file", output::s(target_file.display().to_string())),
186                    ("error", output::s(err.to_string())),
187                ])),
188            );
189        } else {
190            eprintln!(
191                "gemini-save: failed to write target file {}",
192                target_file.display()
193            );
194        }
195        return 1;
196    }
197
198    let _ = write_target_timestamp(&target_file, &auth_file);
199
200    if output_json {
201        let _ = output::emit_result(
202            "auth save",
203            output::obj(vec![
204                ("auth_file", output::s(auth_file.display().to_string())),
205                ("target_file", output::s(target_file.display().to_string())),
206                ("saved", output::b(true)),
207                ("overwritten", output::b(overwritten)),
208            ]),
209        );
210    } else {
211        println!(
212            "gemini: saved {} to {}{}",
213            auth_file.display(),
214            target_file.display(),
215            if overwritten { " (overwritten)" } else { "" }
216        );
217    }
218
219    0
220}
221
222fn usage_error(output_json: bool, message: &str) -> i32 {
223    if output_json {
224        let _ = output::emit_error("auth save", "invalid-usage", message, None);
225    } else {
226        eprintln!("{message}");
227    }
228    64
229}
230
231fn resolve_secret_dir() -> Option<PathBuf> {
232    crate::paths::resolve_secret_dir()
233}
234
235fn is_invalid_target(target: &str) -> bool {
236    target.contains('/') || target.contains('\\') || target.contains("..")
237}
238
239fn interactive_io_available() -> bool {
240    io::stdin().is_terminal() && io::stdout().is_terminal()
241}
242
243fn confirm_overwrite(target: &Path) -> io::Result<bool> {
244    eprint!(
245        "gemini-save: {} exists. overwrite? [y/N]: ",
246        target.display()
247    );
248    io::stderr().flush()?;
249
250    let mut line = String::new();
251    io::stdin().read_line(&mut line)?;
252    let normalized = line.trim().to_ascii_lowercase();
253    Ok(matches!(normalized.as_str(), "y" | "yes"))
254}
255
256fn write_target_timestamp(target_file: &Path, auth_file: &Path) -> io::Result<()> {
257    let Some(timestamp_file) = crate::paths::resolve_secret_timestamp_path(target_file) else {
258        return Ok(());
259    };
260    let iso = auth::last_refresh_from_auth_file(auth_file).ok().flatten();
261    auth::write_timestamp(&timestamp_file, iso.as_deref())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::{is_invalid_target, resolve_secret_dir};
267    use nils_test_support::{EnvGuard, GlobalStateLock};
268
269    #[test]
270    fn invalid_target_rejects_paths_and_traversal() {
271        assert!(is_invalid_target("../a.json"));
272        assert!(is_invalid_target("a/b.json"));
273        assert!(is_invalid_target(r"a\b.json"));
274        assert!(!is_invalid_target("alpha.json"));
275    }
276
277    #[test]
278    fn resolve_secret_dir_uses_gemini_secret_dir_env_override() {
279        let lock = GlobalStateLock::new();
280        let _home_guard = EnvGuard::set(&lock, "HOME", "");
281        let _secret_dir_guard = EnvGuard::set(&lock, "GEMINI_SECRET_DIR", "/tmp/secrets");
282        assert_eq!(
283            resolve_secret_dir().expect("secret dir"),
284            std::path::PathBuf::from("/tmp/secrets")
285        );
286    }
287}