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