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::paths;
9use nils_common::fs;
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 print_secret_dir_setup_hint("codex-save");
50 }
51 return Ok(1);
52 }
53 };
54
55 if !secret_dir.is_dir() {
56 if output_json {
57 output::emit_error(
58 "auth save",
59 "secret-dir-not-found",
60 format!(
61 "codex-save: CODEX_SECRET_DIR not found: {}",
62 secret_dir.display()
63 ),
64 Some(json!({
65 "secret_dir": secret_dir.display().to_string(),
66 })),
67 )?;
68 } else {
69 eprintln!(
70 "codex-save: CODEX_SECRET_DIR not found: {}",
71 secret_dir.display()
72 );
73 print_secret_dir_setup_hint("codex-save");
74 }
75 return Ok(1);
76 }
77
78 let auth_file = match paths::resolve_auth_file() {
79 Some(path) => path,
80 None => {
81 if output_json {
82 output::emit_error(
83 "auth save",
84 "auth-file-not-configured",
85 "codex-save: CODEX_AUTH_FILE is not configured",
86 None,
87 )?;
88 } else {
89 eprintln!("codex-save: CODEX_AUTH_FILE is not configured");
90 }
91 return Ok(1);
92 }
93 };
94
95 if !auth_file.is_file() {
96 if output_json {
97 output::emit_error(
98 "auth save",
99 "auth-file-not-found",
100 format!("codex-save: auth file not found: {}", auth_file.display()),
101 Some(json!({
102 "auth_file": auth_file.display().to_string(),
103 })),
104 )?;
105 } else {
106 eprintln!("codex-save: auth file not found: {}", auth_file.display());
107 }
108 return Ok(1);
109 }
110
111 let secret_name = auth::normalize_secret_file_name(target);
112 let target_file = secret_dir.join(&secret_name);
113 let mut overwritten = false;
114 if target_file.exists() {
115 if yes {
116 overwritten = true;
117 } else if output_json {
118 output::emit_error(
119 "auth save",
120 "overwrite-confirmation-required",
121 format!(
122 "codex-save: {} exists; rerun with --yes to overwrite",
123 target_file.display()
124 ),
125 Some(json!({
126 "target_file": target_file.display().to_string(),
127 "overwritten": false,
128 })),
129 )?;
130 return Ok(1);
131 } else if !interactive_io_available() {
132 eprintln!(
133 "codex-save: {} exists; rerun with --yes to overwrite",
134 target_file.display()
135 );
136 return Ok(1);
137 } else {
138 match confirm_overwrite(&target_file)? {
139 true => {
140 overwritten = true;
141 }
142 false => {
143 eprintln!(
144 "codex-save: overwrite declined for {}",
145 target_file.display()
146 );
147 return Ok(1);
148 }
149 }
150 }
151 }
152
153 let content = match std::fs::read(&auth_file) {
154 Ok(content) => content,
155 Err(_) => {
156 if output_json {
157 output::emit_error(
158 "auth save",
159 "auth-file-read-failed",
160 format!(
161 "codex-save: failed to read auth file: {}",
162 auth_file.display()
163 ),
164 Some(json!({
165 "auth_file": auth_file.display().to_string(),
166 })),
167 )?;
168 } else {
169 eprintln!(
170 "codex-save: failed to read auth file: {}",
171 auth_file.display()
172 );
173 }
174 return Ok(1);
175 }
176 };
177
178 if let Err(err) = fs::write_atomic(&target_file, &content, fs::SECRET_FILE_MODE) {
179 if output_json {
180 output::emit_error(
181 "auth save",
182 "save-write-failed",
183 format!(
184 "codex-save: failed to write target file {}",
185 target_file.display()
186 ),
187 Some(json!({
188 "target_file": target_file.display().to_string(),
189 "error": err.to_string(),
190 })),
191 )?;
192 } else {
193 eprintln!(
194 "codex-save: failed to write target file {}",
195 target_file.display()
196 );
197 }
198 return Ok(1);
199 }
200
201 let _ = write_target_timestamp(&target_file, &auth_file);
202
203 if output_json {
204 output::emit_result(
205 "auth save",
206 AuthSaveResult {
207 auth_file: auth_file.display().to_string(),
208 target_file: target_file.display().to_string(),
209 saved: true,
210 overwritten,
211 },
212 )?;
213 } else {
214 println!(
215 "codex: saved {} to {}{}",
216 auth_file.display(),
217 target_file.display(),
218 if overwritten { " (overwritten)" } else { "" }
219 );
220 }
221
222 Ok(0)
223}
224
225fn usage_error(output_json: bool, message: &str) -> Result<i32> {
226 if output_json {
227 output::emit_error("auth save", "invalid-usage", message, None)?;
228 } else {
229 eprintln!("{message}");
230 }
231 Ok(64)
232}
233
234fn interactive_io_available() -> bool {
235 io::stdin().is_terminal() && io::stdout().is_terminal()
236}
237
238fn print_secret_dir_setup_hint(prefix: &str) {
239 eprintln!("{prefix}: hint: export CODEX_SECRET_DIR=\"$HOME/.config/codex_secrets\"");
240 eprintln!("{prefix}: hint: mkdir -p \"$CODEX_SECRET_DIR\" && chmod 700 \"$CODEX_SECRET_DIR\"");
241}
242
243fn confirm_overwrite(target: &Path) -> Result<bool> {
244 let _ = target;
245 eprint!("codex-save: target file exists. overwrite? [y/N]: ");
246 io::stderr().flush()?;
247
248 let mut line = String::new();
249 io::stdin().read_line(&mut line)?;
250 let normalized = line.trim().to_ascii_lowercase();
251 Ok(matches!(normalized.as_str(), "y" | "yes"))
252}
253
254fn write_target_timestamp(target_file: &Path, auth_file: &Path) -> Result<()> {
255 let Some(timestamp_file) = paths::resolve_secret_timestamp_path(target_file) else {
256 return Ok(());
257 };
258 let iso = auth::last_refresh_from_auth_file(auth_file).unwrap_or(None);
259 fs::write_timestamp(×tamp_file, iso.as_deref())?;
260 Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265 use crate::auth::is_invalid_secret_target;
266 use crate::paths;
267 use nils_test_support::{EnvGuard, GlobalStateLock};
268
269 #[test]
270 fn invalid_target_rejects_paths_and_traversal() {
271 assert!(is_invalid_secret_target("../a.json"));
272 assert!(is_invalid_secret_target("a/b.json"));
273 assert!(is_invalid_secret_target(r"a\b.json"));
274 assert!(!is_invalid_secret_target("alpha.json"));
275 }
276
277 #[test]
278 fn resolve_secret_dir_uses_codex_secret_dir_only() {
279 let lock = GlobalStateLock::new();
280 let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
281 assert_eq!(
282 paths::resolve_secret_dir_from_env().expect("secret dir"),
283 std::path::PathBuf::from("/tmp/secrets")
284 );
285 }
286}