gemini_cli/auth/
remove.rs1use 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-remove: usage: gemini-remove [--yes] <secret.json>",
16 );
17 }
18
19 if is_invalid_target(target) {
20 if output_json {
21 let _ = output::emit_error(
22 "auth remove",
23 "invalid-secret-file-name",
24 format!("gemini-remove: invalid secret file name: {target}"),
25 Some(output::obj(vec![("target", output::s(target))])),
26 );
27 } else {
28 eprintln!("gemini-remove: 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 remove",
39 "secret-dir-not-configured",
40 "gemini-remove: secret directory is not configured",
41 None,
42 );
43 } else {
44 eprintln!("gemini-remove: 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 remove",
54 "secret-dir-not-found",
55 format!(
56 "gemini-remove: 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-remove: secret directory not found: {}",
67 secret_dir.display()
68 );
69 }
70 return 1;
71 }
72
73 let target_file = secret_dir.join(target);
74 if !target_file.is_file() {
75 if output_json {
76 let _ = output::emit_error(
77 "auth remove",
78 "target-not-found",
79 format!(
80 "gemini-remove: secret file not found: {}",
81 target_file.display()
82 ),
83 Some(output::obj(vec![(
84 "target_file",
85 output::s(target_file.display().to_string()),
86 )])),
87 );
88 } else {
89 eprintln!(
90 "gemini-remove: secret file not found: {}",
91 target_file.display()
92 );
93 }
94 return 1;
95 }
96
97 if !yes {
98 if output_json {
99 let _ = output::emit_error(
100 "auth remove",
101 "remove-confirmation-required",
102 format!(
103 "gemini-remove: {} exists; rerun with --yes to remove",
104 target_file.display()
105 ),
106 Some(output::obj(vec![
107 ("target_file", output::s(target_file.display().to_string())),
108 ("removed", output::b(false)),
109 ])),
110 );
111 return 1;
112 }
113
114 if !interactive_io_available() {
115 eprintln!(
116 "gemini-remove: {} exists; rerun with --yes to remove",
117 target_file.display()
118 );
119 return 1;
120 }
121
122 match confirm_remove(&target_file) {
123 Ok(true) => {}
124 Ok(false) => {
125 eprintln!(
126 "gemini-remove: removal declined for {}",
127 target_file.display()
128 );
129 return 1;
130 }
131 Err(_) => return 1,
132 }
133 }
134
135 if let Err(err) = std::fs::remove_file(&target_file) {
136 if output_json {
137 let _ = output::emit_error(
138 "auth remove",
139 "remove-failed",
140 format!("gemini-remove: failed to remove {}", target_file.display()),
141 Some(output::obj(vec![
142 ("target_file", output::s(target_file.display().to_string())),
143 ("error", output::s(err.to_string())),
144 ])),
145 );
146 } else {
147 eprintln!("gemini-remove: failed to remove {}", target_file.display());
148 }
149 return 1;
150 }
151
152 remove_target_timestamp(&target_file);
153
154 if output_json {
155 let _ = output::emit_result(
156 "auth remove",
157 output::obj(vec![
158 ("target_file", output::s(target_file.display().to_string())),
159 ("removed", output::b(true)),
160 ]),
161 );
162 } else {
163 println!("gemini: removed {}", target_file.display());
164 }
165 0
166}
167
168fn usage_error(output_json: bool, message: &str) -> i32 {
169 if output_json {
170 let _ = output::emit_error("auth remove", "invalid-usage", message, None);
171 } else {
172 eprintln!("{message}");
173 }
174 64
175}
176
177fn resolve_secret_dir() -> Option<PathBuf> {
178 crate::paths::resolve_secret_dir()
179}
180
181fn is_invalid_target(target: &str) -> bool {
182 target.contains('/') || target.contains('\\') || target.contains("..")
183}
184
185fn interactive_io_available() -> bool {
186 io::stdin().is_terminal() && io::stdout().is_terminal()
187}
188
189fn confirm_remove(target: &Path) -> io::Result<bool> {
190 eprint!("gemini-remove: remove {}? [y/N]: ", target.display());
191 io::stderr().flush()?;
192
193 let mut line = String::new();
194 io::stdin().read_line(&mut line)?;
195 let normalized = line.trim().to_ascii_lowercase();
196 Ok(matches!(normalized.as_str(), "y" | "yes"))
197}
198
199fn remove_target_timestamp(target_file: &Path) {
200 let Some(timestamp_file) = crate::paths::resolve_secret_timestamp_path(target_file) else {
201 return;
202 };
203 let _ = auth::write_timestamp(×tamp_file, None);
204}
205
206#[cfg(test)]
207mod tests {
208 use super::{is_invalid_target, resolve_secret_dir};
209 use nils_test_support::{EnvGuard, GlobalStateLock};
210
211 #[test]
212 fn invalid_target_rejects_paths_and_traversal() {
213 assert!(is_invalid_target("../a.json"));
214 assert!(is_invalid_target("a/b.json"));
215 assert!(is_invalid_target(r"a\b.json"));
216 assert!(!is_invalid_target("alpha.json"));
217 }
218
219 #[test]
220 fn resolve_secret_dir_uses_gemini_secret_dir_env_override() {
221 let lock = GlobalStateLock::new();
222 let _home_guard = EnvGuard::set(&lock, "HOME", "");
223 let _secret_dir_guard = EnvGuard::set(&lock, "GEMINI_SECRET_DIR", "/tmp/secrets");
224 assert_eq!(
225 resolve_secret_dir().expect("secret dir"),
226 std::path::PathBuf::from("/tmp/secrets")
227 );
228 }
229}