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(×tamp_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}