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, PathBuf};
5
6use crate::auth::output::{self, AuthRemoveResult};
7use crate::paths;
8
9pub fn run(target: &str, yes: bool) -> Result<i32> {
10    run_with_json(target, yes, false)
11}
12
13pub fn run_with_json(target: &str, yes: bool, output_json: bool) -> Result<i32> {
14    if target.is_empty() {
15        return usage_error(
16            output_json,
17            "codex-remove: usage: codex-remove [--yes] <secret.json>",
18        );
19    }
20
21    if is_invalid_target(target) {
22        if output_json {
23            output::emit_error(
24                "auth remove",
25                "invalid-secret-file-name",
26                format!("codex-remove: invalid secret file name: {target}"),
27                Some(json!({ "target": target })),
28            )?;
29        } else {
30            eprintln!("codex-remove: invalid secret file name: {target}");
31        }
32        return Ok(64);
33    }
34
35    let secret_dir = match resolve_secret_dir_from_env() {
36        Some(path) => path,
37        None => {
38            if output_json {
39                output::emit_error(
40                    "auth remove",
41                    "secret-dir-not-configured",
42                    "codex-remove: CODEX_SECRET_DIR is not configured",
43                    None,
44                )?;
45            } else {
46                eprintln!("codex-remove: CODEX_SECRET_DIR is not configured");
47            }
48            return Ok(1);
49        }
50    };
51
52    if !secret_dir.is_dir() {
53        if output_json {
54            output::emit_error(
55                "auth remove",
56                "secret-dir-not-found",
57                format!(
58                    "codex-remove: CODEX_SECRET_DIR not found: {}",
59                    secret_dir.display()
60                ),
61                Some(json!({
62                    "secret_dir": secret_dir.display().to_string(),
63                })),
64            )?;
65        } else {
66            eprintln!(
67                "codex-remove: CODEX_SECRET_DIR not found: {}",
68                secret_dir.display()
69            );
70        }
71        return Ok(1);
72    }
73
74    let target_file = secret_dir.join(target);
75    if !target_file.is_file() {
76        if output_json {
77            output::emit_error(
78                "auth remove",
79                "target-not-found",
80                format!(
81                    "codex-remove: secret file not found: {}",
82                    target_file.display()
83                ),
84                Some(json!({
85                    "target_file": target_file.display().to_string(),
86                })),
87            )?;
88        } else {
89            eprintln!(
90                "codex-remove: secret file not found: {}",
91                target_file.display()
92            );
93        }
94        return Ok(1);
95    }
96
97    if !yes {
98        if output_json {
99            output::emit_error(
100                "auth remove",
101                "remove-confirmation-required",
102                format!(
103                    "codex-remove: {} exists; rerun with --yes to remove",
104                    target_file.display()
105                ),
106                Some(json!({
107                    "target_file": target_file.display().to_string(),
108                    "removed": false,
109                })),
110            )?;
111            return Ok(1);
112        }
113
114        if !interactive_io_available() {
115            eprintln!(
116                "codex-remove: {} exists; rerun with --yes to remove",
117                target_file.display()
118            );
119            return Ok(1);
120        }
121
122        if !confirm_remove(&target_file)? {
123            eprintln!(
124                "codex-remove: removal declined for {}",
125                target_file.display()
126            );
127            return Ok(1);
128        }
129    }
130
131    if let Err(err) = std::fs::remove_file(&target_file) {
132        if output_json {
133            output::emit_error(
134                "auth remove",
135                "remove-failed",
136                format!("codex-remove: failed to remove {}", target_file.display()),
137                Some(json!({
138                    "target_file": target_file.display().to_string(),
139                    "error": err.to_string(),
140                })),
141            )?;
142        } else {
143            eprintln!("codex-remove: failed to remove {}", target_file.display());
144        }
145        return Ok(1);
146    }
147
148    remove_target_timestamp(&target_file);
149
150    if output_json {
151        output::emit_result(
152            "auth remove",
153            AuthRemoveResult {
154                target_file: target_file.display().to_string(),
155                removed: true,
156            },
157        )?;
158    } else {
159        println!("codex: removed {}", target_file.display());
160    }
161    Ok(0)
162}
163
164fn usage_error(output_json: bool, message: &str) -> Result<i32> {
165    if output_json {
166        output::emit_error("auth remove", "invalid-usage", message, None)?;
167    } else {
168        eprintln!("{message}");
169    }
170    Ok(64)
171}
172
173fn resolve_secret_dir_from_env() -> Option<PathBuf> {
174    let raw = std::env::var_os("CODEX_SECRET_DIR")?;
175    if raw.is_empty() {
176        return None;
177    }
178    Some(PathBuf::from(raw))
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) -> Result<bool> {
190    eprint!("codex-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) = 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 _ = std::fs::remove_file(timestamp_file);
209}
210
211#[cfg(test)]
212mod tests {
213    use super::{is_invalid_target, resolve_secret_dir_from_env};
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_codex_secret_dir_only() {
226        let lock = GlobalStateLock::new();
227        let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
228        assert_eq!(
229            resolve_secret_dir_from_env().expect("secret dir"),
230            std::path::PathBuf::from("/tmp/secrets")
231        );
232    }
233}