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::paths;
9use nils_common::fs;
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                print_secret_dir_setup_hint("codex-remove");
50            }
51            return Ok(1);
52        }
53    };
54
55    if !secret_dir.is_dir() {
56        if output_json {
57            output::emit_error(
58                "auth remove",
59                "secret-dir-not-found",
60                format!(
61                    "codex-remove: CODEX_SECRET_DIR not found: {}",
62                    secret_dir.display()
63                ),
64                Some(json!({
65                    "secret_dir": secret_dir.display().to_string(),
66                })),
67            )?;
68        } else {
69            eprintln!(
70                "codex-remove: CODEX_SECRET_DIR not found: {}",
71                secret_dir.display()
72            );
73            print_secret_dir_setup_hint("codex-remove");
74        }
75        return Ok(1);
76    }
77
78    let secret_name = auth::normalize_secret_file_name(target);
79    let target_file = secret_dir.join(&secret_name);
80    if !target_file.is_file() {
81        if output_json {
82            output::emit_error(
83                "auth remove",
84                "target-not-found",
85                format!(
86                    "codex-remove: secret file not found: {}",
87                    target_file.display()
88                ),
89                Some(json!({
90                    "target_file": target_file.display().to_string(),
91                })),
92            )?;
93        } else {
94            eprintln!(
95                "codex-remove: secret file not found: {}",
96                target_file.display()
97            );
98        }
99        return Ok(1);
100    }
101
102    if !yes {
103        if !interactive_io_available() {
104            emit_confirmation_usage_error(output_json, &target_file)?;
105            return Ok(64);
106        }
107        if output_json {
108            emit_confirmation_usage_error(true, &target_file)?;
109            return Ok(64);
110        }
111
112        if !confirm_remove(&target_file)? {
113            eprintln!(
114                "codex-remove: removal declined for {}",
115                target_file.display()
116            );
117            return Ok(1);
118        }
119    }
120
121    if let Err(err) = std::fs::remove_file(&target_file) {
122        if output_json {
123            output::emit_error(
124                "auth remove",
125                "remove-failed",
126                format!("codex-remove: failed to remove {}", target_file.display()),
127                Some(json!({
128                    "target_file": target_file.display().to_string(),
129                    "error": err.to_string(),
130                })),
131            )?;
132        } else {
133            eprintln!("codex-remove: failed to remove {}", target_file.display());
134        }
135        return Ok(1);
136    }
137
138    remove_target_timestamp(&target_file);
139
140    if output_json {
141        output::emit_result(
142            "auth remove",
143            AuthRemoveResult {
144                target_file: target_file.display().to_string(),
145                removed: true,
146            },
147        )?;
148    } else {
149        println!("codex: removed {}", target_file.display());
150    }
151    Ok(0)
152}
153
154fn emit_confirmation_usage_error(output_json: bool, target_file: &Path) -> Result<()> {
155    if output_json {
156        output::emit_error(
157            "auth remove",
158            "usage-error",
159            format!(
160                "codex-remove: {} exists; rerun with --yes to remove",
161                target_file.display()
162            ),
163            Some(json!({
164                "target_file": target_file.display().to_string(),
165                "removed": false,
166            })),
167        )?;
168    } else {
169        eprintln!(
170            "codex-remove: {} exists; rerun with --yes to remove",
171            target_file.display()
172        );
173    }
174    Ok(())
175}
176
177fn usage_error(output_json: bool, message: &str) -> Result<i32> {
178    if output_json {
179        output::emit_error("auth remove", "invalid-usage", message, None)?;
180    } else {
181        eprintln!("{message}");
182    }
183    Ok(64)
184}
185
186fn interactive_io_available() -> bool {
187    io::stdin().is_terminal() && io::stdout().is_terminal()
188}
189
190fn print_secret_dir_setup_hint(prefix: &str) {
191    eprintln!("{prefix}: hint: export CODEX_SECRET_DIR=\"$HOME/.config/codex_secrets\"");
192    eprintln!("{prefix}: hint: mkdir -p \"$CODEX_SECRET_DIR\" && chmod 700 \"$CODEX_SECRET_DIR\"");
193}
194
195fn confirm_remove(target: &Path) -> Result<bool> {
196    let _ = target;
197    eprint!("codex-remove: remove target file? [y/N]: ");
198    io::stderr().flush()?;
199
200    let mut line = String::new();
201    io::stdin().read_line(&mut line)?;
202    let normalized = line.trim().to_ascii_lowercase();
203    Ok(matches!(normalized.as_str(), "y" | "yes"))
204}
205
206fn remove_target_timestamp(target_file: &Path) {
207    let Some(timestamp_file) = paths::resolve_secret_timestamp_path(target_file) else {
208        return;
209    };
210    let _ = fs::write_timestamp(&timestamp_file, None);
211}
212
213#[cfg(test)]
214mod tests {
215    use crate::auth::is_invalid_secret_target;
216    use crate::paths;
217    use nils_test_support::{EnvGuard, GlobalStateLock};
218
219    #[test]
220    fn invalid_target_rejects_paths_and_traversal() {
221        assert!(is_invalid_secret_target("../a.json"));
222        assert!(is_invalid_secret_target("a/b.json"));
223        assert!(is_invalid_secret_target(r"a\b.json"));
224        assert!(!is_invalid_secret_target("alpha.json"));
225    }
226
227    #[test]
228    fn resolve_secret_dir_uses_codex_secret_dir_only() {
229        let lock = GlobalStateLock::new();
230        let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
231        assert_eq!(
232            paths::resolve_secret_dir_from_env().expect("secret dir"),
233            std::path::PathBuf::from("/tmp/secrets")
234        );
235    }
236}