1use anyhow::Result;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4
5use crate::auth;
6use crate::auth::output::{self, AuthUseResult};
7use crate::fs;
8use crate::paths;
9
10pub fn run(target: &str) -> Result<i32> {
11 run_with_json(target, false)
12}
13
14pub fn run_with_json(target: &str, output_json: bool) -> Result<i32> {
15 if target.is_empty() {
16 if output_json {
17 output::emit_error(
18 "auth use",
19 "invalid-usage",
20 "codex-use: usage: codex-use <name|name.json|email>",
21 None,
22 )?;
23 } else {
24 eprintln!("codex-use: usage: codex-use <name|name.json|email>");
25 }
26 return Ok(64);
27 }
28
29 if target.contains('/') || target.contains("..") {
30 if output_json {
31 output::emit_error(
32 "auth use",
33 "invalid-secret-name",
34 format!("codex-use: invalid secret name: {target}"),
35 Some(json!({
36 "target": target,
37 })),
38 )?;
39 } else {
40 eprintln!("codex-use: invalid secret name: {target}");
41 }
42 return Ok(64);
43 }
44
45 let secret_dir = match paths::resolve_secret_dir() {
46 Some(dir) => dir,
47 None => {
48 if output_json {
49 output::emit_error(
50 "auth use",
51 "secret-not-found",
52 format!("codex-use: secret not found: {target}"),
53 Some(json!({
54 "target": target,
55 })),
56 )?;
57 } else {
58 eprintln!("codex-use: secret not found: {target}");
59 }
60 return Ok(1);
61 }
62 };
63
64 let is_email = target.contains('@');
65 let mut secret_name = target.to_string();
66 if !secret_name.ends_with(".json") && !is_email {
67 secret_name.push_str(".json");
68 }
69
70 if secret_dir.join(&secret_name).is_file() {
71 let (code, auth_file) = apply_secret(&secret_dir, &secret_name, output_json)?;
72 if output_json && code == 0 {
73 output::emit_result(
74 "auth use",
75 AuthUseResult {
76 target: target.to_string(),
77 matched_secret: Some(secret_name),
78 applied: true,
79 auth_file: auth_file.unwrap_or_default(),
80 },
81 )?;
82 }
83 return Ok(code);
84 }
85
86 match resolve_by_email(&secret_dir, target) {
87 ResolveResult::Exact(name) => {
88 let (code, auth_file) = apply_secret(&secret_dir, &name, output_json)?;
89 if output_json && code == 0 {
90 output::emit_result(
91 "auth use",
92 AuthUseResult {
93 target: target.to_string(),
94 matched_secret: Some(name),
95 applied: true,
96 auth_file: auth_file.unwrap_or_default(),
97 },
98 )?;
99 }
100 Ok(code)
101 }
102 ResolveResult::Ambiguous { candidates } => {
103 if output_json {
104 output::emit_error(
105 "auth use",
106 "ambiguous-secret",
107 format!("codex-use: identifier matches multiple secrets: {target}"),
108 Some(json!({
109 "target": target,
110 "candidates": candidates,
111 })),
112 )?;
113 } else {
114 eprintln!("codex-use: identifier matches multiple secrets: {target}");
115 eprintln!("codex-use: candidates: {}", candidates.join(", "));
116 }
117 Ok(2)
118 }
119 ResolveResult::NotFound => {
120 if output_json {
121 output::emit_error(
122 "auth use",
123 "secret-not-found",
124 format!("codex-use: secret not found: {target}"),
125 Some(json!({
126 "target": target,
127 })),
128 )?;
129 } else {
130 eprintln!("codex-use: secret not found: {target}");
131 }
132 Ok(1)
133 }
134 }
135}
136
137fn apply_secret(
138 secret_dir: &Path,
139 secret_name: &str,
140 output_json: bool,
141) -> Result<(i32, Option<String>)> {
142 let source_file = secret_dir.join(secret_name);
143 if !source_file.is_file() {
144 if !output_json {
145 eprintln!("codex: secret file {secret_name} not found");
146 }
147 return Ok((1, None));
148 }
149
150 let auth_file = match paths::resolve_auth_file() {
151 Some(path) => path,
152 None => return Ok((1, None)),
153 };
154
155 if auth_file.is_file() {
156 let sync_result = crate::auth::sync::run_with_json(false)?;
157 if sync_result != 0 {
158 if !output_json {
159 eprintln!("codex: failed to sync current auth before switching secrets");
160 }
161 return Ok((1, None));
162 }
163 }
164
165 let contents = std::fs::read(&source_file)?;
166 fs::write_atomic(&auth_file, &contents, fs::SECRET_FILE_MODE)?;
167
168 let iso = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
169 let timestamp_path = secret_timestamp_path(&auth_file)?;
170 fs::write_timestamp(×tamp_path, iso.as_deref())?;
171
172 if !output_json {
173 println!("codex: applied {secret_name} to {}", auth_file.display());
174 }
175 Ok((0, Some(auth_file.display().to_string())))
176}
177
178fn resolve_by_email(secret_dir: &Path, target: &str) -> ResolveResult {
179 let query = target.to_lowercase();
180 let want_full = target.contains('@');
181
182 let mut matches = Vec::new();
183 if let Ok(entries) = std::fs::read_dir(secret_dir) {
184 for entry in entries.flatten() {
185 let path = entry.path();
186 if path.extension().and_then(|s| s.to_str()) != Some("json") {
187 continue;
188 }
189 let email = match auth::email_from_auth_file(&path) {
190 Ok(Some(value)) => value,
191 _ => continue,
192 };
193 let email_lower = email.to_lowercase();
194 if want_full {
195 if email_lower == query {
196 matches.push(file_name(&path));
197 }
198 } else if let Some(local_part) = email_lower.split('@').next()
199 && local_part == query
200 {
201 matches.push(file_name(&path));
202 }
203 }
204 }
205
206 if matches.len() == 1 {
207 ResolveResult::Exact(matches.remove(0))
208 } else if matches.is_empty() {
209 ResolveResult::NotFound
210 } else {
211 ResolveResult::Ambiguous {
212 candidates: matches,
213 }
214 }
215}
216
217fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
218 let cache_dir = paths::resolve_secret_cache_dir()
219 .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
220 let name = target_file
221 .file_name()
222 .and_then(|name| name.to_str())
223 .unwrap_or("auth.json");
224 Ok(cache_dir.join(format!("{name}.timestamp")))
225}
226
227fn file_name(path: &Path) -> String {
228 path.file_name()
229 .and_then(|name| name.to_str())
230 .unwrap_or_default()
231 .to_string()
232}
233
234#[derive(Debug)]
235enum ResolveResult {
236 Exact(String),
237 Ambiguous { candidates: Vec<String> },
238 NotFound,
239}
240
241#[cfg(test)]
242mod tests {
243 use super::{ResolveResult, file_name, resolve_by_email, secret_timestamp_path};
244 use pretty_assertions::assert_eq;
245 use std::path::Path;
246
247 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
248 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
249 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
250
251 fn token(payload: &str) -> String {
252 format!("{HEADER}.{payload}.sig")
253 }
254
255 fn auth_json(payload: &str) -> String {
256 format!(
257 r#"{{"tokens":{{"id_token":"{}","access_token":"{}"}}}}"#,
258 token(payload),
259 token(payload)
260 )
261 }
262
263 struct EnvGuard {
264 key: &'static str,
265 old: Option<std::ffi::OsString>,
266 }
267
268 impl EnvGuard {
269 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
270 let old = std::env::var_os(key);
271 unsafe { std::env::set_var(key, value) };
273 Self { key, old }
274 }
275 }
276
277 impl Drop for EnvGuard {
278 fn drop(&mut self) {
279 if let Some(value) = self.old.take() {
280 unsafe { std::env::set_var(self.key, value) };
282 } else {
283 unsafe { std::env::remove_var(self.key) };
285 }
286 }
287 }
288
289 #[test]
290 fn resolve_by_email_supports_full_and_local_part_lookup() {
291 let dir = tempfile::TempDir::new().expect("tempdir");
292 std::fs::write(dir.path().join("alpha.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha");
293 std::fs::write(dir.path().join("beta.json"), auth_json(PAYLOAD_BETA)).expect("beta");
294
295 match resolve_by_email(dir.path(), "alpha@example.com") {
296 ResolveResult::Exact(name) => assert_eq!(name, "alpha.json"),
297 other => panic!("expected exact alpha match, got {other:?}"),
298 }
299 match resolve_by_email(dir.path(), "beta") {
300 ResolveResult::Exact(name) => assert_eq!(name, "beta.json"),
301 other => panic!("expected exact beta match, got {other:?}"),
302 }
303 }
304
305 #[test]
306 fn resolve_by_email_reports_ambiguous_and_not_found() {
307 let dir = tempfile::TempDir::new().expect("tempdir");
308 std::fs::write(dir.path().join("alpha-1.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-1");
309 std::fs::write(dir.path().join("alpha-2.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-2");
310
311 match resolve_by_email(dir.path(), "alpha@example.com") {
312 ResolveResult::Ambiguous { candidates } => {
313 assert_eq!(candidates.len(), 2);
314 assert!(candidates.contains(&"alpha-1.json".to_string()));
315 assert!(candidates.contains(&"alpha-2.json".to_string()));
316 }
317 other => panic!("expected ambiguous match, got {other:?}"),
318 }
319
320 match resolve_by_email(dir.path(), "missing@example.com") {
321 ResolveResult::NotFound => {}
322 other => panic!("expected not found, got {other:?}"),
323 }
324 }
325
326 #[test]
327 fn secret_timestamp_path_uses_cache_dir_and_default_file_name() {
328 let dir = tempfile::TempDir::new().expect("tempdir");
329 let cache = dir.path().join("cache");
330 std::fs::create_dir_all(&cache).expect("cache");
331 let _guard = EnvGuard::set("CODEX_SECRET_CACHE_DIR", &cache);
332
333 let with_name =
334 secret_timestamp_path(Path::new("/tmp/demo-auth.json")).expect("timestamp path");
335 assert_eq!(with_name, cache.join("demo-auth.json.timestamp"));
336
337 let without_name = secret_timestamp_path(Path::new("")).expect("timestamp path");
338 assert_eq!(without_name, cache.join("auth.json.timestamp"));
339 }
340
341 #[test]
342 fn file_name_returns_empty_when_path_has_no_file_name() {
343 assert_eq!(file_name(Path::new("a/b/c.json")), "c.json");
344 assert_eq!(file_name(Path::new("")), "");
345 }
346}