Skip to main content

gemini_cli/auth/
remove.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-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(cache_dir) = crate::paths::resolve_secret_cache_dir() else {
201        return;
202    };
203    let file_name = target_file
204        .file_name()
205        .and_then(|v| v.to_str())
206        .unwrap_or("auth.json");
207    let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
208    let _ = auth::write_timestamp(&timestamp_file, None);
209}
210
211#[cfg(test)]
212mod tests {
213    use super::{is_invalid_target, resolve_secret_dir};
214    use nils_test_support::{EnvGuard, GlobalStateLock};
215
216    #[test]
217    fn invalid_target_rejects_paths_and_traversal() {
218        assert!(is_invalid_target("../a.json"));
219        assert!(is_invalid_target("a/b.json"));
220        assert!(is_invalid_target(r"a\b.json"));
221        assert!(!is_invalid_target("alpha.json"));
222    }
223
224    #[test]
225    fn resolve_secret_dir_uses_gemini_secret_dir_env_override() {
226        let lock = GlobalStateLock::new();
227        let _home_guard = EnvGuard::set(&lock, "HOME", "");
228        let _secret_dir_guard = EnvGuard::set(&lock, "GEMINI_SECRET_DIR", "/tmp/secrets");
229        assert_eq!(
230            resolve_secret_dir().expect("secret dir"),
231            std::path::PathBuf::from("/tmp/secrets")
232        );
233    }
234}