1use anyhow::Result;
2use serde_json::json;
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use crate::auth;
7use crate::auth::output::{self, AuthSaveResult};
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-save: usage: codex-save [--yes] <secret.json>",
20 );
21 }
22
23 if is_invalid_target(target) {
24 if output_json {
25 output::emit_error(
26 "auth save",
27 "invalid-secret-file-name",
28 format!("codex-save: invalid secret file name: {target}"),
29 Some(json!({ "target": target })),
30 )?;
31 } else {
32 eprintln!("codex-save: invalid secret file name: {target}");
33 }
34 return Ok(64);
35 }
36
37 let secret_dir = match resolve_secret_dir_from_env() {
38 Some(path) => path,
39 None => {
40 if output_json {
41 output::emit_error(
42 "auth save",
43 "secret-dir-not-configured",
44 "codex-save: CODEX_SECRET_DIR is not configured",
45 None,
46 )?;
47 } else {
48 eprintln!("codex-save: 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 save",
58 "secret-dir-not-found",
59 format!(
60 "codex-save: 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-save: CODEX_SECRET_DIR not found: {}",
70 secret_dir.display()
71 );
72 }
73 return Ok(1);
74 }
75
76 let auth_file = match paths::resolve_auth_file() {
77 Some(path) => path,
78 None => {
79 if output_json {
80 output::emit_error(
81 "auth save",
82 "auth-file-not-configured",
83 "codex-save: CODEX_AUTH_FILE is not configured",
84 None,
85 )?;
86 } else {
87 eprintln!("codex-save: CODEX_AUTH_FILE is not configured");
88 }
89 return Ok(1);
90 }
91 };
92
93 if !auth_file.is_file() {
94 if output_json {
95 output::emit_error(
96 "auth save",
97 "auth-file-not-found",
98 format!("codex-save: auth file not found: {}", auth_file.display()),
99 Some(json!({
100 "auth_file": auth_file.display().to_string(),
101 })),
102 )?;
103 } else {
104 eprintln!("codex-save: auth file not found: {}", auth_file.display());
105 }
106 return Ok(1);
107 }
108
109 let target_file = secret_dir.join(target);
110 let mut overwritten = false;
111 if target_file.exists() {
112 if yes {
113 overwritten = true;
114 } else if output_json {
115 output::emit_error(
116 "auth save",
117 "overwrite-confirmation-required",
118 format!(
119 "codex-save: {} exists; rerun with --yes to overwrite",
120 target_file.display()
121 ),
122 Some(json!({
123 "target_file": target_file.display().to_string(),
124 "overwritten": false,
125 })),
126 )?;
127 return Ok(1);
128 } else if !interactive_io_available() {
129 eprintln!(
130 "codex-save: {} exists; rerun with --yes to overwrite",
131 target_file.display()
132 );
133 return Ok(1);
134 } else {
135 match confirm_overwrite(&target_file)? {
136 true => {
137 overwritten = true;
138 }
139 false => {
140 eprintln!(
141 "codex-save: overwrite declined for {}",
142 target_file.display()
143 );
144 return Ok(1);
145 }
146 }
147 }
148 }
149
150 let content = match std::fs::read(&auth_file) {
151 Ok(content) => content,
152 Err(_) => {
153 if output_json {
154 output::emit_error(
155 "auth save",
156 "auth-file-read-failed",
157 format!(
158 "codex-save: failed to read auth file: {}",
159 auth_file.display()
160 ),
161 Some(json!({
162 "auth_file": auth_file.display().to_string(),
163 })),
164 )?;
165 } else {
166 eprintln!(
167 "codex-save: failed to read auth file: {}",
168 auth_file.display()
169 );
170 }
171 return Ok(1);
172 }
173 };
174
175 if let Err(err) = fs::write_atomic(&target_file, &content, fs::SECRET_FILE_MODE) {
176 if output_json {
177 output::emit_error(
178 "auth save",
179 "save-write-failed",
180 format!(
181 "codex-save: failed to write target file {}",
182 target_file.display()
183 ),
184 Some(json!({
185 "target_file": target_file.display().to_string(),
186 "error": err.to_string(),
187 })),
188 )?;
189 } else {
190 eprintln!(
191 "codex-save: failed to write target file {}",
192 target_file.display()
193 );
194 }
195 return Ok(1);
196 }
197
198 let _ = write_target_timestamp(&target_file, &auth_file);
199
200 if output_json {
201 output::emit_result(
202 "auth save",
203 AuthSaveResult {
204 auth_file: auth_file.display().to_string(),
205 target_file: target_file.display().to_string(),
206 saved: true,
207 overwritten,
208 },
209 )?;
210 } else {
211 println!(
212 "codex: saved {} to {}{}",
213 auth_file.display(),
214 target_file.display(),
215 if overwritten { " (overwritten)" } else { "" }
216 );
217 }
218
219 Ok(0)
220}
221
222fn usage_error(output_json: bool, message: &str) -> Result<i32> {
223 if output_json {
224 output::emit_error("auth save", "invalid-usage", message, None)?;
225 } else {
226 eprintln!("{message}");
227 }
228 Ok(64)
229}
230
231fn resolve_secret_dir_from_env() -> Option<PathBuf> {
232 let raw = std::env::var_os("CODEX_SECRET_DIR")?;
233 if raw.is_empty() {
234 return None;
235 }
236 Some(PathBuf::from(raw))
237}
238
239fn is_invalid_target(target: &str) -> bool {
240 target.contains('/') || target.contains('\\') || target.contains("..")
241}
242
243fn interactive_io_available() -> bool {
244 io::stdin().is_terminal() && io::stdout().is_terminal()
245}
246
247fn confirm_overwrite(target: &Path) -> Result<bool> {
248 eprint!(
249 "codex-save: {} exists. overwrite? [y/N]: ",
250 target.display()
251 );
252 io::stderr().flush()?;
253
254 let mut line = String::new();
255 io::stdin().read_line(&mut line)?;
256 let normalized = line.trim().to_ascii_lowercase();
257 Ok(matches!(normalized.as_str(), "y" | "yes"))
258}
259
260fn write_target_timestamp(target_file: &Path, auth_file: &Path) -> Result<()> {
261 let cache_dir = match paths::resolve_secret_cache_dir() {
262 Some(dir) => dir,
263 None => return Ok(()),
264 };
265
266 let file_name = target_file
267 .file_name()
268 .and_then(|v| v.to_str())
269 .unwrap_or("auth.json");
270 let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
271 let iso = auth::last_refresh_from_auth_file(auth_file).unwrap_or(None);
272 fs::write_timestamp(×tamp_file, iso.as_deref())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::{is_invalid_target, resolve_secret_dir_from_env};
278 use nils_test_support::{EnvGuard, GlobalStateLock};
279
280 #[test]
281 fn invalid_target_rejects_paths_and_traversal() {
282 assert!(is_invalid_target("../a.json"));
283 assert!(is_invalid_target("a/b.json"));
284 assert!(is_invalid_target(r"a\b.json"));
285 assert!(!is_invalid_target("alpha.json"));
286 }
287
288 #[test]
289 fn resolve_secret_dir_uses_codex_secret_dir_only() {
290 let lock = GlobalStateLock::new();
291 let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
292 assert_eq!(
293 resolve_secret_dir_from_env().expect("secret dir"),
294 std::path::PathBuf::from("/tmp/secrets")
295 );
296 }
297}