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::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 paths::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 is_invalid_target(target: &str) -> bool {
174    target.contains('/') || target.contains('\\') || target.contains("..")
175}
176
177fn interactive_io_available() -> bool {
178    io::stdin().is_terminal() && io::stdout().is_terminal()
179}
180
181fn confirm_remove(target: &Path) -> Result<bool> {
182    eprint!("codex-remove: remove {}? [y/N]: ", target.display());
183    io::stderr().flush()?;
184
185    let mut line = String::new();
186    io::stdin().read_line(&mut line)?;
187    let normalized = line.trim().to_ascii_lowercase();
188    Ok(matches!(normalized.as_str(), "y" | "yes"))
189}
190
191fn remove_target_timestamp(target_file: &Path) {
192    let Some(cache_dir) = paths::resolve_secret_cache_dir() else {
193        return;
194    };
195    let file_name = target_file
196        .file_name()
197        .and_then(|v| v.to_str())
198        .unwrap_or("auth.json");
199    let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
200    let _ = std::fs::remove_file(timestamp_file);
201}
202
203#[cfg(test)]
204mod tests {
205    use super::is_invalid_target;
206    use crate::paths;
207    use nils_test_support::{EnvGuard, GlobalStateLock};
208
209    #[test]
210    fn invalid_target_rejects_paths_and_traversal() {
211        assert!(is_invalid_target("../a.json"));
212        assert!(is_invalid_target("a/b.json"));
213        assert!(is_invalid_target(r"a\b.json"));
214        assert!(!is_invalid_target("alpha.json"));
215    }
216
217    #[test]
218    fn resolve_secret_dir_uses_codex_secret_dir_only() {
219        let lock = GlobalStateLock::new();
220        let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
221        assert_eq!(
222            paths::resolve_secret_dir_from_env().expect("secret dir"),
223            std::path::PathBuf::from("/tmp/secrets")
224        );
225    }
226}