Skip to main content

codex_cli/auth/
save.rs

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(&timestamp_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}