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