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 cache_dir = match crate::paths::resolve_secret_cache_dir() {
258 Some(dir) => dir,
259 None => return Ok(()),
260 };
261
262 let file_name = target_file
263 .file_name()
264 .and_then(|v| v.to_str())
265 .unwrap_or("auth.json");
266 let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
267 let iso = auth::last_refresh_from_auth_file(auth_file).ok().flatten();
268 auth::write_timestamp(×tamp_file, iso.as_deref())
269}
270
271#[cfg(test)]
272mod tests {
273 use super::{is_invalid_target, resolve_secret_dir};
274 use nils_test_support::{EnvGuard, GlobalStateLock};
275
276 #[test]
277 fn invalid_target_rejects_paths_and_traversal() {
278 assert!(is_invalid_target("../a.json"));
279 assert!(is_invalid_target("a/b.json"));
280 assert!(is_invalid_target(r"a\b.json"));
281 assert!(!is_invalid_target("alpha.json"));
282 }
283
284 #[test]
285 fn resolve_secret_dir_uses_gemini_secret_dir_env_override() {
286 let lock = GlobalStateLock::new();
287 let _home_guard = EnvGuard::set(&lock, "HOME", "");
288 let _secret_dir_guard = EnvGuard::set(&lock, "GEMINI_SECRET_DIR", "/tmp/secrets");
289 assert_eq!(
290 resolve_secret_dir().expect("secret dir"),
291 std::path::PathBuf::from("/tmp/secrets")
292 );
293 }
294}