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, 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|secret.json>",
20 );
21 }
22
23 if auth::is_invalid_secret_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 paths::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 secret_name = auth::normalize_secret_file_name(target);
110 let target_file = secret_dir.join(&secret_name);
111 let mut overwritten = false;
112 if target_file.exists() {
113 if yes {
114 overwritten = true;
115 } else if output_json {
116 output::emit_error(
117 "auth save",
118 "overwrite-confirmation-required",
119 format!(
120 "codex-save: {} exists; rerun with --yes to overwrite",
121 target_file.display()
122 ),
123 Some(json!({
124 "target_file": target_file.display().to_string(),
125 "overwritten": false,
126 })),
127 )?;
128 return Ok(1);
129 } else if !interactive_io_available() {
130 eprintln!(
131 "codex-save: {} exists; rerun with --yes to overwrite",
132 target_file.display()
133 );
134 return Ok(1);
135 } else {
136 match confirm_overwrite(&target_file)? {
137 true => {
138 overwritten = true;
139 }
140 false => {
141 eprintln!(
142 "codex-save: overwrite declined for {}",
143 target_file.display()
144 );
145 return Ok(1);
146 }
147 }
148 }
149 }
150
151 let content = match std::fs::read(&auth_file) {
152 Ok(content) => content,
153 Err(_) => {
154 if output_json {
155 output::emit_error(
156 "auth save",
157 "auth-file-read-failed",
158 format!(
159 "codex-save: failed to read auth file: {}",
160 auth_file.display()
161 ),
162 Some(json!({
163 "auth_file": auth_file.display().to_string(),
164 })),
165 )?;
166 } else {
167 eprintln!(
168 "codex-save: failed to read auth file: {}",
169 auth_file.display()
170 );
171 }
172 return Ok(1);
173 }
174 };
175
176 if let Err(err) = fs::write_atomic(&target_file, &content, fs::SECRET_FILE_MODE) {
177 if output_json {
178 output::emit_error(
179 "auth save",
180 "save-write-failed",
181 format!(
182 "codex-save: failed to write target file {}",
183 target_file.display()
184 ),
185 Some(json!({
186 "target_file": target_file.display().to_string(),
187 "error": err.to_string(),
188 })),
189 )?;
190 } else {
191 eprintln!(
192 "codex-save: failed to write target file {}",
193 target_file.display()
194 );
195 }
196 return Ok(1);
197 }
198
199 let _ = write_target_timestamp(&target_file, &auth_file);
200
201 if output_json {
202 output::emit_result(
203 "auth save",
204 AuthSaveResult {
205 auth_file: auth_file.display().to_string(),
206 target_file: target_file.display().to_string(),
207 saved: true,
208 overwritten,
209 },
210 )?;
211 } else {
212 println!(
213 "codex: saved {} to {}{}",
214 auth_file.display(),
215 target_file.display(),
216 if overwritten { " (overwritten)" } else { "" }
217 );
218 }
219
220 Ok(0)
221}
222
223fn usage_error(output_json: bool, message: &str) -> Result<i32> {
224 if output_json {
225 output::emit_error("auth save", "invalid-usage", message, None)?;
226 } else {
227 eprintln!("{message}");
228 }
229 Ok(64)
230}
231
232fn interactive_io_available() -> bool {
233 io::stdin().is_terminal() && io::stdout().is_terminal()
234}
235
236fn confirm_overwrite(target: &Path) -> Result<bool> {
237 eprint!(
238 "codex-save: {} exists. overwrite? [y/N]: ",
239 target.display()
240 );
241 io::stderr().flush()?;
242
243 let mut line = String::new();
244 io::stdin().read_line(&mut line)?;
245 let normalized = line.trim().to_ascii_lowercase();
246 Ok(matches!(normalized.as_str(), "y" | "yes"))
247}
248
249fn write_target_timestamp(target_file: &Path, auth_file: &Path) -> Result<()> {
250 let cache_dir = match paths::resolve_secret_cache_dir() {
251 Some(dir) => dir,
252 None => return Ok(()),
253 };
254
255 let file_name = target_file
256 .file_name()
257 .and_then(|v| v.to_str())
258 .unwrap_or("auth.json");
259 let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
260 let iso = auth::last_refresh_from_auth_file(auth_file).unwrap_or(None);
261 fs::write_timestamp(×tamp_file, iso.as_deref())
262}
263
264#[cfg(test)]
265mod tests {
266 use crate::auth::is_invalid_secret_target;
267 use crate::paths;
268 use nils_test_support::{EnvGuard, GlobalStateLock};
269
270 #[test]
271 fn invalid_target_rejects_paths_and_traversal() {
272 assert!(is_invalid_secret_target("../a.json"));
273 assert!(is_invalid_secret_target("a/b.json"));
274 assert!(is_invalid_secret_target(r"a\b.json"));
275 assert!(!is_invalid_secret_target("alpha.json"));
276 }
277
278 #[test]
279 fn resolve_secret_dir_uses_codex_secret_dir_only() {
280 let lock = GlobalStateLock::new();
281 let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
282 assert_eq!(
283 paths::resolve_secret_dir_from_env().expect("secret dir"),
284 std::path::PathBuf::from("/tmp/secrets")
285 );
286 }
287}