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.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 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 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 is_invalid_target(target: &str) -> bool {
232    target.contains('/') || target.contains('\\') || target.contains("..")
233}
234
235fn interactive_io_available() -> bool {
236    io::stdin().is_terminal() && io::stdout().is_terminal()
237}
238
239fn confirm_overwrite(target: &Path) -> Result<bool> {
240    eprint!(
241        "codex-save: {} exists. overwrite? [y/N]: ",
242        target.display()
243    );
244    io::stderr().flush()?;
245
246    let mut line = String::new();
247    io::stdin().read_line(&mut line)?;
248    let normalized = line.trim().to_ascii_lowercase();
249    Ok(matches!(normalized.as_str(), "y" | "yes"))
250}
251
252fn write_target_timestamp(target_file: &Path, auth_file: &Path) -> Result<()> {
253    let cache_dir = match paths::resolve_secret_cache_dir() {
254        Some(dir) => dir,
255        None => return Ok(()),
256    };
257
258    let file_name = target_file
259        .file_name()
260        .and_then(|v| v.to_str())
261        .unwrap_or("auth.json");
262    let timestamp_file = cache_dir.join(format!("{file_name}.timestamp"));
263    let iso = auth::last_refresh_from_auth_file(auth_file).unwrap_or(None);
264    fs::write_timestamp(&timestamp_file, iso.as_deref())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::is_invalid_target;
270    use crate::paths;
271    use nils_test_support::{EnvGuard, GlobalStateLock};
272
273    #[test]
274    fn invalid_target_rejects_paths_and_traversal() {
275        assert!(is_invalid_target("../a.json"));
276        assert!(is_invalid_target("a/b.json"));
277        assert!(is_invalid_target(r"a\b.json"));
278        assert!(!is_invalid_target("alpha.json"));
279    }
280
281    #[test]
282    fn resolve_secret_dir_uses_codex_secret_dir_only() {
283        let lock = GlobalStateLock::new();
284        let _set = EnvGuard::set(&lock, "CODEX_SECRET_DIR", "/tmp/secrets");
285        assert_eq!(
286            paths::resolve_secret_dir_from_env().expect("secret dir"),
287            std::path::PathBuf::from("/tmp/secrets")
288        );
289    }
290}