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}