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 auth::is_invalid_secret_target(target) {
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 secret_name = if is_email {
66 target.to_string()
67 } else {
68 auth::normalize_secret_file_name(target)
69 };
70
71 if secret_dir.join(&secret_name).is_file() {
72 let (code, auth_file) = apply_secret(&secret_dir, &secret_name, output_json)?;
73 if output_json && code == 0 {
74 output::emit_result(
75 "auth use",
76 AuthUseResult {
77 target: target.to_string(),
78 matched_secret: Some(secret_name),
79 applied: true,
80 auth_file: auth_file.unwrap_or_default(),
81 },
82 )?;
83 }
84 return Ok(code);
85 }
86
87 match resolve_by_email(&secret_dir, target) {
88 ResolveResult::Exact(name) => {
89 let (code, auth_file) = apply_secret(&secret_dir, &name, output_json)?;
90 if output_json && code == 0 {
91 output::emit_result(
92 "auth use",
93 AuthUseResult {
94 target: target.to_string(),
95 matched_secret: Some(name),
96 applied: true,
97 auth_file: auth_file.unwrap_or_default(),
98 },
99 )?;
100 }
101 Ok(code)
102 }
103 ResolveResult::Ambiguous { candidates } => {
104 if output_json {
105 output::emit_error(
106 "auth use",
107 "ambiguous-secret",
108 format!("codex-use: identifier matches multiple secrets: {target}"),
109 Some(json!({
110 "target": target,
111 "candidates": candidates,
112 })),
113 )?;
114 } else {
115 eprintln!("codex-use: identifier matches multiple secrets: {target}");
116 eprintln!("codex-use: candidates: {}", candidates.join(", "));
117 }
118 Ok(2)
119 }
120 ResolveResult::NotFound => {
121 if output_json {
122 output::emit_error(
123 "auth use",
124 "secret-not-found",
125 format!("codex-use: secret not found: {target}"),
126 Some(json!({
127 "target": target,
128 })),
129 )?;
130 } else {
131 eprintln!("codex-use: secret not found: {target}");
132 }
133 Ok(1)
134 }
135 }
136}
137
138fn apply_secret(
139 secret_dir: &Path,
140 secret_name: &str,
141 output_json: bool,
142) -> Result<(i32, Option<String>)> {
143 let source_file = secret_dir.join(secret_name);
144 if !source_file.is_file() {
145 if !output_json {
146 eprintln!("codex: secret file {secret_name} not found");
147 }
148 return Ok((1, None));
149 }
150
151 let auth_file = match paths::resolve_auth_file() {
152 Some(path) => path,
153 None => return Ok((1, None)),
154 };
155
156 if auth_file.is_file() {
157 let sync_result = crate::auth::sync::run_with_json(false)?;
158 if sync_result != 0 {
159 if !output_json {
160 eprintln!("codex: failed to sync current auth before switching secrets");
161 }
162 return Ok((1, None));
163 }
164 }
165
166 let contents = std::fs::read(&source_file)?;
167 fs::write_atomic(&auth_file, &contents, fs::SECRET_FILE_MODE)?;
168
169 let iso = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
170 let timestamp_path = secret_timestamp_path(&auth_file)?;
171 fs::write_timestamp(×tamp_path, iso.as_deref())?;
172
173 if !output_json {
174 println!("codex: applied {secret_name} to {}", auth_file.display());
175 }
176 Ok((0, Some(auth_file.display().to_string())))
177}
178
179fn resolve_by_email(secret_dir: &Path, target: &str) -> ResolveResult {
180 let query = target.to_lowercase();
181 let want_full = target.contains('@');
182
183 let mut matches = Vec::new();
184 if let Ok(entries) = std::fs::read_dir(secret_dir) {
185 for entry in entries.flatten() {
186 let path = entry.path();
187 if path.extension().and_then(|s| s.to_str()) != Some("json") {
188 continue;
189 }
190 let email = match auth::email_from_auth_file(&path) {
191 Ok(Some(value)) => value,
192 _ => continue,
193 };
194 let email_lower = email.to_lowercase();
195 if want_full {
196 if email_lower == query {
197 matches.push(file_name(&path));
198 }
199 } else if let Some(local_part) = email_lower.split('@').next()
200 && local_part == query
201 {
202 matches.push(file_name(&path));
203 }
204 }
205 }
206
207 if matches.len() == 1 {
208 ResolveResult::Exact(matches.remove(0))
209 } else if matches.is_empty() {
210 ResolveResult::NotFound
211 } else {
212 ResolveResult::Ambiguous {
213 candidates: matches,
214 }
215 }
216}
217
218fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
219 let cache_dir = paths::resolve_secret_cache_dir()
220 .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
221 let name = target_file
222 .file_name()
223 .and_then(|name| name.to_str())
224 .unwrap_or("auth.json");
225 Ok(cache_dir.join(format!("{name}.timestamp")))
226}
227
228fn file_name(path: &Path) -> String {
229 path.file_name()
230 .and_then(|name| name.to_str())
231 .unwrap_or_default()
232 .to_string()
233}
234
235#[derive(Debug)]
236enum ResolveResult {
237 Exact(String),
238 Ambiguous { candidates: Vec<String> },
239 NotFound,
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{ResolveResult, file_name, resolve_by_email, secret_timestamp_path};
245 use nils_test_support::{EnvGuard, GlobalStateLock};
246 use pretty_assertions::assert_eq;
247 use std::path::Path;
248
249 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
250 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
251 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
252
253 fn token(payload: &str) -> String {
254 format!("{HEADER}.{payload}.sig")
255 }
256
257 fn auth_json(payload: &str) -> String {
258 format!(
259 r#"{{"tokens":{{"id_token":"{}","access_token":"{}"}}}}"#,
260 token(payload),
261 token(payload)
262 )
263 }
264
265 #[test]
266 fn resolve_by_email_supports_full_and_local_part_lookup() {
267 let dir = tempfile::TempDir::new().expect("tempdir");
268 std::fs::write(dir.path().join("alpha.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha");
269 std::fs::write(dir.path().join("beta.json"), auth_json(PAYLOAD_BETA)).expect("beta");
270
271 match resolve_by_email(dir.path(), "alpha@example.com") {
272 ResolveResult::Exact(name) => assert_eq!(name, "alpha.json"),
273 other => panic!("expected exact alpha match, got {other:?}"),
274 }
275 match resolve_by_email(dir.path(), "beta") {
276 ResolveResult::Exact(name) => assert_eq!(name, "beta.json"),
277 other => panic!("expected exact beta match, got {other:?}"),
278 }
279 }
280
281 #[test]
282 fn resolve_by_email_reports_ambiguous_and_not_found() {
283 let dir = tempfile::TempDir::new().expect("tempdir");
284 std::fs::write(dir.path().join("alpha-1.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-1");
285 std::fs::write(dir.path().join("alpha-2.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-2");
286
287 match resolve_by_email(dir.path(), "alpha@example.com") {
288 ResolveResult::Ambiguous { candidates } => {
289 assert_eq!(candidates.len(), 2);
290 assert!(candidates.contains(&"alpha-1.json".to_string()));
291 assert!(candidates.contains(&"alpha-2.json".to_string()));
292 }
293 other => panic!("expected ambiguous match, got {other:?}"),
294 }
295
296 match resolve_by_email(dir.path(), "missing@example.com") {
297 ResolveResult::NotFound => {}
298 other => panic!("expected not found, got {other:?}"),
299 }
300 }
301
302 #[test]
303 fn secret_timestamp_path_uses_cache_dir_and_default_file_name() {
304 let lock = GlobalStateLock::new();
305 let dir = tempfile::TempDir::new().expect("tempdir");
306 let cache = dir.path().join("cache");
307 std::fs::create_dir_all(&cache).expect("cache");
308 let cache_value = cache.to_string_lossy().to_string();
309 let _guard = EnvGuard::set(&lock, "CODEX_SECRET_CACHE_DIR", &cache_value);
310
311 let with_name =
312 secret_timestamp_path(Path::new("/tmp/demo-auth.json")).expect("timestamp path");
313 assert_eq!(with_name, cache.join("demo-auth.json.timestamp"));
314
315 let without_name = secret_timestamp_path(Path::new("")).expect("timestamp path");
316 assert_eq!(without_name, cache.join("auth.json.timestamp"));
317 }
318
319 #[test]
320 fn file_name_returns_empty_when_path_has_no_file_name() {
321 assert_eq!(file_name(Path::new("a/b/c.json")), "c.json");
322 assert_eq!(file_name(Path::new("")), "");
323 }
324}