Skip to main content

codex_cli/auth/
remove.rs

1use anyhow::Result;
2use serde_json::json;
3use std::io::{self, IsTerminal, Write};
4use std::path::Path;
5
6use crate::auth::output::{self, AuthRemoveResult};
7use crate::fs;
8use crate::paths;
9
10pub fn run(target: &str, yes: bool) -> Result<i32> {
11    run_with_json(target, yes, false)
12}
13
14pub fn run_with_json(target: &str, yes: bool, output_json: bool) -> Result<i32> {
15    if target.is_empty() {
16        return usage_error(
17            output_json,
18            "codex-remove: usage: codex-remove [--yes] <secret.json>",
19        );
20    }
21
22    if is_invalid_target(target) {
23        if output_json {
24            output::emit_error(
25                "auth remove",
26                "invalid-secret-file-name",
27                format!("codex-remove: invalid secret file name: {target}"),
28                Some(json!({ "target": target })),
29            )?;
30        } else {
31            eprintln!("codex-remove: invalid secret file name: {target}");
32        }
33        return Ok(64);
34    }
35
36    let secret_dir = match paths::resolve_secret_dir_from_env() {
37        Some(path) => path,
38        None => {
39            if output_json {
40                output::emit_error(
41                    "auth remove",
42                    "secret-dir-not-configured",
43                    "codex-remove: CODEX_SECRET_DIR is not configured",
44                    None,
45                )?;
46            } else {
47                eprintln!("codex-remove: CODEX_SECRET_DIR is not configured");
48            }
49            return Ok(1);
50        }
51    };
52
53    if !secret_dir.is_dir() {
54        if output_json {
55            output::emit_error(
56                "auth remove",
57                "secret-dir-not-found",
58                format!(
59                    "codex-remove: CODEX_SECRET_DIR not found: {}",
60                    secret_dir.display()
61                ),
62                Some(json!({
63                    "secret_dir": secret_dir.display().to_string(),
64                })),
65            )?;
66        } else {
67            eprintln!(
68                "codex-remove: CODEX_SECRET_DIR not found: {}",
69                secret_dir.display()
70            );
71        }
72        return Ok(1);
73    }
74
75    let target_file = secret_dir.join(target);
76    if !target_file.is_file() {
77        if output_json {
78            output::emit_error(
79                "auth remove",
80                "target-not-found",
81                format!(
82                    "codex-remove: secret file not found: {}",
83                    target_file.display()
84                ),
85                Some(json!({
86                    "target_file": target_file.display().to_string(),
87                })),
88            )?;
89        } else {
90            eprintln!(
91                "codex-remove: secret file not found: {}",
92                target_file.display()
93            );
94        }
95        return Ok(1);
96    }
97
98    if !yes {
99        if output_json {
100            output::emit_error(
101                "auth remove",
102                "remove-confirmation-required",
103                format!(
104                    "codex-remove: {} exists; rerun with --yes to remove",
105                    target_file.display()
106                ),
107                Some(json!({
108                    "target_file": target_file.display().to_string(),
109                    "removed": false,
110                })),
111            )?;
112            return Ok(1);
113        }
114
115        if !interactive_io_available() {
116            eprintln!(
117                "codex-remove: {} exists; rerun with --yes to remove",
118                target_file.display()
119            );
120            return Ok(1);
121        }
122
123        if !confirm_remove(&target_file)? {
124            eprintln!(
125                "codex-remove: removal declined for {}",
126                target_file.display()
127            );
128            return Ok(1);
129        }
130    }
131
132    if let Err(err) = std::fs::remove_file(&target_file) {
133        if output_json {
134            output::emit_error(
135                "auth remove",
136                "remove-failed",
137                format!("codex-remove: failed to remove {}", target_file.display()),
138                Some(json!({
139                    "target_file": target_file.display().to_string(),
140                    "error": err.to_string(),
141                })),
142            )?;
143        } else {
144            eprintln!("codex-remove: failed to remove {}", target_file.display());
145        }
146        return Ok(1);
147    }
148
149    remove_target_timestamp(&target_file);
150
151    if output_json {
152        output::emit_result(
153            "auth remove",
154            AuthRemoveResult {
155                target_file: target_file.display().to_string(),
156                removed: true,
157            },
158        )?;
159    } else {
160        println!("codex: removed {}", target_file.display());
161    }
162    Ok(0)
163}
164
165fn usage_error(output_json: bool, message: &str) -> Result<i32> {
166    if output_json {
167        output::emit_error("auth remove", "invalid-usage", message, None)?;
168    } else {
169        eprintln!("{message}");
170    }
171    Ok(64)
172}
173
174fn is_invalid_target(target: &str) -> bool {
175    target.contains('/') || target.contains('\\') || target.contains("..")
176}
177
178fn interactive_io_available() -> bool {
179    io::stdin().is_terminal() && io::stdout().is_terminal()
180}
181
182fn confirm_remove(target: &Path) -> Result<bool> {
183    eprint!("codex-remove: remove {}? [y/N]: ", target.display());
184    io::stderr().flush()?;
185
186    let mut line = String::new();
187    io::stdin().read_line(&mut line)?;
188    let normalized = line.trim().to_ascii_lowercase();
189    Ok(matches!(normalized.as_str(), "y" | "yes"))
190}
191
192fn remove_target_timestamp(target_file: &Path) {
193    let Some(cache_dir) = paths::resolve_secret_cache_dir() else {
194        return;
195    };
196    let file_name = target_file
197        .file_name()
198        .and_then(|v| v.to_str())
199        .unwrap_or("auth.json");
200    let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
201    let _ = fs::write_timestamp(&timestamp_file, None);
202}
203
204#[cfg(test)]
205mod tests {
206    use super::is_invalid_target;
207    use crate::paths;
208    use nils_test_support::{EnvGuard, GlobalStateLock};
209
210    #[test]
211    fn invalid_target_rejects_paths_and_traversal() {
212        assert!(is_invalid_target("../a.json"));
213        assert!(is_invalid_target("a/b.json"));
214        assert!(is_invalid_target(r"a\b.json"));
215        assert!(!is_invalid_target("alpha.json"));
216    }
217
218    #[test]
219    fn resolve_secret_dir_uses_codex_secret_dir_only() {
220        let lock = GlobalStateLock::new();
221        let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
222        assert_eq!(
223            paths::resolve_secret_dir_from_env().expect("secret dir"),
224            std::path::PathBuf::from("/tmp/secrets")
225        );
226    }
227}