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