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