1use directories::BaseDirs;
2use std::env;
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7
8#[cfg(test)]
9use std::cell::Cell;
10#[cfg(test)]
11use std::sync::Mutex;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct Paths {
15 pub codex: PathBuf,
16 pub auth: PathBuf,
17 pub profiles: PathBuf,
18 pub profiles_index: PathBuf,
19 pub profiles_lock: PathBuf,
20}
21
22pub fn command_name() -> &'static str {
23 static COMMAND_NAME: OnceLock<String> = OnceLock::new();
24 COMMAND_NAME
25 .get_or_init(|| {
26 let env_value = env::var("CODEX_PROFILES_COMMAND").ok();
27 compute_command_name_from(env_value, env::args_os())
28 })
29 .as_str()
30}
31
32fn compute_command_name_from<I>(env_value: Option<String>, mut args: I) -> String
33where
34 I: Iterator<Item = std::ffi::OsString>,
35{
36 if let Some(value) = env_value {
37 let trimmed = value.trim();
38 if !trimmed.is_empty() {
39 return trimmed.to_string();
40 }
41 }
42 args.next()
43 .and_then(|arg| {
44 Path::new(&arg)
45 .file_name()
46 .and_then(|name| name.to_str())
47 .map(|name| name.to_string())
48 })
49 .filter(|name| !name.is_empty())
50 .unwrap_or_else(|| "codex-profiles".to_string())
51}
52
53pub fn package_command_name() -> &'static str {
54 "codex-profiles"
55}
56
57#[cfg(unix)]
58const FAIL_SET_PERMISSIONS: usize = 1;
59const FAIL_WRITE_OPEN: usize = 2;
60const FAIL_WRITE_WRITE: usize = 3;
61const FAIL_WRITE_PERMS: usize = 4;
62const FAIL_WRITE_SYNC: usize = 5;
63const FAIL_WRITE_RENAME: usize = 6;
64
65#[cfg(test)]
66thread_local! {
67 static FAILPOINT: Cell<usize> = const { Cell::new(0) };
68}
69#[cfg(test)]
70static FAILPOINT_LOCK: Mutex<()> = Mutex::new(());
71
72#[cfg(test)]
73fn maybe_fail(step: usize) -> std::io::Result<()> {
74 if FAILPOINT.with(|failpoint| failpoint.get()) == step {
75 return Err(std::io::Error::other("failpoint"));
76 }
77 Ok(())
78}
79
80#[cfg(not(test))]
81fn maybe_fail(_step: usize) -> std::io::Result<()> {
82 Ok(())
83}
84
85pub fn resolve_paths() -> Result<Paths, String> {
86 let home_dir =
87 resolve_home_dir().ok_or_else(|| "Error: could not resolve home directory".to_string())?;
88 let codex_dir = home_dir.join(".codex");
89 let auth = codex_dir.join("auth.json");
90 let profiles = codex_dir.join("profiles");
91 let profiles_index = profiles.join("profiles.json");
92 let profiles_lock = profiles.join("profiles.lock");
93 Ok(Paths {
94 codex: codex_dir,
95 auth,
96 profiles,
97 profiles_index,
98 profiles_lock,
99 })
100}
101
102fn resolve_home_dir() -> Option<PathBuf> {
103 let codex_home = env::var_os("CODEX_PROFILES_HOME").map(PathBuf::from);
104 let base_home = BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
105 let home = env::var_os("HOME").map(PathBuf::from);
106 let userprofile = env::var_os("USERPROFILE").map(PathBuf::from);
107 let homedrive = env::var_os("HOMEDRIVE").map(PathBuf::from);
108 let homepath = env::var_os("HOMEPATH").map(PathBuf::from);
109 resolve_home_dir_with(
110 codex_home,
111 base_home,
112 home,
113 userprofile,
114 homedrive,
115 homepath,
116 )
117}
118
119fn resolve_home_dir_with(
120 codex_home: Option<PathBuf>,
121 base_home: Option<PathBuf>,
122 home: Option<PathBuf>,
123 userprofile: Option<PathBuf>,
124 homedrive: Option<PathBuf>,
125 homepath: Option<PathBuf>,
126) -> Option<PathBuf> {
127 if let Some(path) = non_empty_path(codex_home) {
128 return Some(path);
129 }
130 if let Some(path) = base_home {
131 return Some(path);
132 }
133 if let Some(path) = non_empty_path(home) {
134 return Some(path);
135 }
136 if let Some(path) = non_empty_path(userprofile) {
137 return Some(path);
138 }
139 match (homedrive, homepath) {
140 (Some(drive), Some(path)) => {
141 let mut out = drive;
142 out.push(path);
143 if out.as_os_str().is_empty() {
144 None
145 } else {
146 Some(out)
147 }
148 }
149 _ => None,
150 }
151}
152
153fn non_empty_path(path: Option<PathBuf>) -> Option<PathBuf> {
154 path.filter(|path| !path.as_os_str().is_empty())
155}
156
157pub fn ensure_paths(paths: &Paths) -> Result<(), String> {
158 if paths.profiles.exists() && !paths.profiles.is_dir() {
159 return Err(format!(
160 "Error: {} exists and is not a directory",
161 paths.profiles.display()
162 ));
163 }
164
165 fs::create_dir_all(&paths.profiles).map_err(|err| {
166 format!(
167 "Error: cannot create profiles directory {}: {err}",
168 paths.profiles.display()
169 )
170 })?;
171
172 #[cfg(unix)]
173 {
174 use std::os::unix::fs::PermissionsExt;
175 let perms = fs::Permissions::from_mode(0o700);
176 if let Err(err) = set_profile_permissions(&paths.profiles, perms) {
177 return Err(format!(
178 "Error: cannot set permissions on {}: {err}",
179 paths.profiles.display()
180 ));
181 }
182 }
183
184 ensure_file_or_absent(&paths.profiles_index)?;
185 ensure_file_or_absent(&paths.profiles_lock)?;
186
187 OpenOptions::new()
188 .create(true)
189 .append(true)
190 .open(&paths.profiles_lock)
191 .map_err(|err| {
192 format!(
193 "Error: cannot write profiles lock file {}: {err}",
194 paths.profiles_lock.display()
195 )
196 })?;
197
198 Ok(())
199}
200
201pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), String> {
202 let permissions = fs::metadata(path).ok().map(|meta| meta.permissions());
203 write_atomic_with_permissions(path, contents, permissions)
204}
205
206pub fn write_atomic_with_mode(path: &Path, contents: &[u8], mode: u32) -> Result<(), String> {
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::PermissionsExt;
210 let permissions = fs::Permissions::from_mode(mode);
211 write_atomic_with_permissions(path, contents, Some(permissions))
212 }
213 #[cfg(not(unix))]
214 {
215 let _ = mode;
216 write_atomic_with_permissions(path, contents, None)
217 }
218}
219
220fn write_atomic_with_permissions(
221 path: &Path,
222 contents: &[u8],
223 permissions: Option<fs::Permissions>,
224) -> Result<(), String> {
225 let parent = path.parent().ok_or_else(|| {
226 format!(
227 "Error: cannot resolve parent directory for {}",
228 path.display()
229 )
230 })?;
231 if !parent.as_os_str().is_empty() {
232 fs::create_dir_all(parent)
233 .map_err(|err| format!("Error: cannot create directory {}: {err}", parent.display()))?;
234 }
235
236 let file_name = path
237 .file_name()
238 .and_then(|name| name.to_str())
239 .ok_or_else(|| format!("Error: invalid file name {}", path.display()))?;
240 let pid = std::process::id();
241 let mut attempt = 0u32;
242 loop {
243 let nanos = SystemTime::now()
244 .duration_since(UNIX_EPOCH)
245 .map_err(|err| format!("Error: failed to get time: {err}"))?
246 .as_nanos();
247 let tmp_name = format!(".{file_name}.tmp-{pid}-{nanos}-{attempt}");
248 let tmp_path = parent.join(tmp_name);
249 let mut options = OpenOptions::new();
250 options.write(true).create_new(true);
251 #[cfg(unix)]
252 if let Some(permissions) = permissions.as_ref() {
253 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
254 options.mode(permissions.mode());
255 }
256 let mut tmp_file = match options.open(&tmp_path).and_then(|file| {
257 maybe_fail(FAIL_WRITE_OPEN)?;
258 Ok(file)
259 }) {
260 Ok(file) => file,
261 Err(err) => {
262 attempt += 1;
263 if attempt < 5 {
264 continue;
265 }
266 return Err(format!(
267 "Error: failed to create temp file for {}: {err}",
268 path.display()
269 ));
270 }
271 };
272
273 maybe_fail(FAIL_WRITE_WRITE)
274 .and_then(|_| tmp_file.write_all(contents))
275 .map_err(|err| {
276 format!(
277 "Error: failed to write temp file for {}: {err}",
278 path.display()
279 )
280 })?;
281
282 if let Some(permissions) = permissions {
283 maybe_fail(FAIL_WRITE_PERMS)
284 .and_then(|_| fs::set_permissions(&tmp_path, permissions))
285 .map_err(|err| {
286 format!(
287 "Error: failed to set temp file permissions for {}: {err}",
288 path.display()
289 )
290 })?;
291 }
292
293 maybe_fail(FAIL_WRITE_SYNC)
294 .and_then(|_| tmp_file.sync_all())
295 .map_err(|err| {
296 format!(
297 "Error: failed to write temp file for {}: {err}",
298 path.display()
299 )
300 })?;
301
302 let rename_result = maybe_fail(FAIL_WRITE_RENAME).and_then(|_| fs::rename(&tmp_path, path));
303 match rename_result {
304 Ok(()) => return Ok(()),
305 Err(err) => {
306 #[cfg(windows)]
307 {
308 if path.exists() {
309 let _ = fs::remove_file(path);
310 }
311 if fs::rename(&tmp_path, path).is_ok() {
312 return Ok(());
313 }
314 }
315 let _ = fs::remove_file(&tmp_path);
316 return Err(format!(
317 "Error: failed to replace {}: {err}",
318 path.display()
319 ));
320 }
321 }
322 }
323}
324
325pub fn copy_atomic(source: &Path, dest: &Path) -> Result<(), String> {
326 let permissions = fs::metadata(source)
327 .map_err(|err| {
328 format!(
329 "Error: failed to read metadata for {}: {err}",
330 source.display()
331 )
332 })?
333 .permissions();
334 let contents = fs::read(source)
335 .map_err(|err| format!("Error: failed to read {}: {err}", source.display()))?;
336 write_atomic_with_permissions(dest, &contents, Some(permissions))
337}
338
339fn ensure_file_or_absent(path: &Path) -> Result<(), String> {
340 if path.exists() && !path.is_file() {
341 return Err(format!(
342 "Error: {} exists and is not a file",
343 path.display()
344 ));
345 }
346 Ok(())
347}
348
349#[cfg(unix)]
350fn set_profile_permissions(path: &Path, perms: fs::Permissions) -> std::io::Result<()> {
351 maybe_fail(FAIL_SET_PERMISSIONS)?;
352 fs::set_permissions(path, perms)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::test_utils::make_paths;
359 use std::ffi::OsString;
360 use std::fs;
361
362 fn with_failpoint<F: FnOnce()>(step: usize, f: F) {
363 let _guard = FAILPOINT_LOCK.lock().unwrap();
364 let prev = FAILPOINT.with(|failpoint| {
365 let prev = failpoint.get();
366 failpoint.set(step);
367 prev
368 });
369 f();
370 FAILPOINT.with(|failpoint| failpoint.set(prev));
371 }
372
373 fn with_failpoint_disabled<F: FnOnce()>(f: F) {
374 let _guard = FAILPOINT_LOCK.lock().unwrap();
375 let prev = FAILPOINT.with(|failpoint| {
376 let prev = failpoint.get();
377 failpoint.set(0);
378 prev
379 });
380 f();
381 FAILPOINT.with(|failpoint| failpoint.set(prev));
382 }
383
384 #[test]
385 fn compute_command_name_uses_env() {
386 let name = compute_command_name_from(Some("mycmd".to_string()), Vec::new().into_iter());
387 assert_eq!(name, "mycmd");
388 }
389
390 #[test]
391 fn compute_command_name_uses_args() {
392 let args = vec![OsString::from("/usr/bin/codex-profiles")];
393 let name = compute_command_name_from(None, args.into_iter());
394 assert_eq!(name, "codex-profiles");
395 }
396
397 #[test]
398 fn compute_command_name_ignores_blank_env() {
399 let args = vec![OsString::from("/usr/local/bin/custom")];
400 let name = compute_command_name_from(Some(" ".to_string()), args.into_iter());
401 assert_eq!(name, "custom");
402 }
403
404 #[test]
405 fn compute_command_name_fallback() {
406 let name = compute_command_name_from(None, Vec::new().into_iter());
407 assert_eq!(name, "codex-profiles");
408 }
409
410 #[test]
411 fn resolve_home_dir_prefers_codex_env() {
412 let out = resolve_home_dir_with(
413 Some(PathBuf::from("/tmp/codex")),
414 Some(PathBuf::from("/tmp/base")),
415 Some(PathBuf::from("/tmp/home")),
416 None,
417 None,
418 None,
419 )
420 .unwrap();
421 assert_eq!(out, PathBuf::from("/tmp/codex"));
422 }
423
424 #[test]
425 fn resolve_home_dir_uses_base_dirs() {
426 let out = resolve_home_dir_with(
427 None,
428 Some(PathBuf::from("/tmp/base")),
429 None,
430 None,
431 None,
432 None,
433 )
434 .unwrap();
435 assert_eq!(out, PathBuf::from("/tmp/base"));
436 }
437
438 #[test]
439 fn resolve_home_dir_falls_back() {
440 let out = resolve_home_dir_with(
441 Some(PathBuf::from("")),
442 None,
443 Some(PathBuf::from("/tmp/home")),
444 Some(PathBuf::from("/tmp/user")),
445 Some(PathBuf::from("C:")),
446 Some(PathBuf::from("/Users")),
447 )
448 .unwrap();
449 assert_eq!(out, PathBuf::from("/tmp/home"));
450 }
451
452 #[test]
453 fn resolve_home_dir_uses_userprofile() {
454 let out = resolve_home_dir_with(
455 None,
456 None,
457 None,
458 Some(PathBuf::from("/tmp/user")),
459 None,
460 None,
461 )
462 .unwrap();
463 assert_eq!(out, PathBuf::from("/tmp/user"));
464 }
465
466 #[test]
467 fn resolve_home_dir_uses_drive() {
468 let out = resolve_home_dir_with(
469 None,
470 None,
471 None,
472 None,
473 Some(PathBuf::from("C:")),
474 Some(PathBuf::from("Users")),
475 )
476 .unwrap();
477 assert_eq!(out, PathBuf::from("C:/Users"));
478 }
479
480 #[test]
481 fn resolve_home_dir_none_when_empty() {
482 assert!(resolve_home_dir_with(None, None, None, None, None, None).is_none());
483 }
484
485 #[test]
486 fn resolve_home_dir_ignores_empty_values() {
487 assert!(
488 resolve_home_dir_with(None, None, Some(PathBuf::from("")), None, None, None,).is_none()
489 );
490 assert!(
491 resolve_home_dir_with(None, None, None, Some(PathBuf::from("")), None, None,).is_none()
492 );
493 assert!(
494 resolve_home_dir_with(
495 None,
496 None,
497 None,
498 None,
499 Some(PathBuf::from("")),
500 Some(PathBuf::from("")),
501 )
502 .is_none()
503 );
504 }
505
506 #[test]
507 fn ensure_paths_errors_when_profiles_is_file() {
508 let dir = tempfile::tempdir().expect("tempdir");
509 let profiles = dir.path().join("profiles");
510 fs::write(&profiles, "not a dir").expect("write");
511 let paths = make_paths(dir.path());
512 let err = ensure_paths(&paths).unwrap_err();
513 assert!(err.contains("not a directory"));
514 }
515
516 #[cfg(unix)]
517 #[test]
518 fn ensure_paths_errors_when_unwritable() {
519 use std::os::unix::fs::PermissionsExt;
520 let dir = tempfile::tempdir().expect("tempdir");
521 let locked = dir.path().join("locked");
522 fs::create_dir_all(&locked).expect("create");
523 fs::set_permissions(&locked, fs::Permissions::from_mode(0o400)).expect("chmod");
524 let profiles = locked.join("profiles");
525 let mut paths = make_paths(dir.path());
526 paths.profiles = profiles.clone();
527 paths.profiles_index = profiles.join("profiles.json");
528 paths.profiles_lock = profiles.join("profiles.lock");
529 let err = ensure_paths(&paths).unwrap_err();
530 assert!(err.contains("cannot create profiles directory"));
531 }
532
533 #[cfg(unix)]
534 #[test]
535 fn ensure_paths_permissions_error() {
536 let dir = tempfile::tempdir().expect("tempdir");
537 let paths = make_paths(dir.path());
538 with_failpoint(FAIL_SET_PERMISSIONS, || {
539 let err = ensure_paths(&paths).unwrap_err();
540 assert!(err.contains("cannot set permissions"));
541 });
542 }
543
544 #[cfg(unix)]
545 #[test]
546 fn ensure_paths_profiles_lock_open_error() {
547 use std::os::unix::fs::PermissionsExt;
548 let dir = tempfile::tempdir().expect("tempdir");
549 let profiles = dir.path().join("profiles");
550 fs::create_dir_all(&profiles).expect("create");
551 let lock = profiles.join("profiles.lock");
552 fs::write(&lock, "").expect("write lock");
553 fs::set_permissions(&lock, fs::Permissions::from_mode(0o400)).expect("chmod");
554 let mut paths = make_paths(dir.path());
555 paths.profiles_lock = lock.clone();
556 let err = ensure_paths(&paths).unwrap_err();
557 assert!(err.contains("cannot write profiles lock file"));
558 }
559
560 #[test]
561 fn write_atomic_success() {
562 with_failpoint_disabled(|| {
563 let dir = tempfile::tempdir().expect("tempdir");
564 let path = dir.path().join("file.txt");
565 write_atomic(&path, b"hello").unwrap();
566 assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
567 });
568 }
569
570 #[test]
571 fn write_atomic_invalid_parent() {
572 let err = write_atomic(Path::new(""), b"hi").unwrap_err();
573 assert!(err.contains("parent directory"));
574 }
575
576 #[test]
577 fn write_atomic_invalid_filename() {
578 let err = write_atomic(Path::new("/"), b"hi").unwrap_err();
579 assert!(err.contains("invalid file name") || err.contains("parent directory"));
580 }
581
582 #[test]
583 fn write_atomic_create_dir_error() {
584 let dir = tempfile::tempdir().expect("tempdir");
585 let blocker = dir.path().join("blocker");
586 fs::write(&blocker, "file").expect("write");
587 let path = blocker.join("child.txt");
588 let err = write_atomic(&path, b"data").unwrap_err();
589 assert!(err.contains("cannot create directory"));
590 }
591
592 #[test]
593 fn write_atomic_open_error() {
594 let dir = tempfile::tempdir().expect("tempdir");
595 let path = dir.path().join("file.txt");
596 with_failpoint(FAIL_WRITE_OPEN, || {
597 let err = write_atomic(&path, b"data").unwrap_err();
598 assert!(err.contains("failed to create temp file"));
599 });
600 }
601
602 #[test]
603 fn write_atomic_write_error() {
604 let dir = tempfile::tempdir().expect("tempdir");
605 let path = dir.path().join("file.txt");
606 with_failpoint(FAIL_WRITE_WRITE, || {
607 let err = write_atomic(&path, b"data").unwrap_err();
608 assert!(err.contains("failed to write temp file"));
609 });
610 }
611
612 #[test]
613 fn write_atomic_permissions_error() {
614 let dir = tempfile::tempdir().expect("tempdir");
615 let path = dir.path().join("file.txt");
616 with_failpoint(FAIL_WRITE_PERMS, || {
617 let err = write_atomic_with_mode(&path, b"data", 0o600).unwrap_err();
618 assert!(err.contains("failed to set temp file permissions"));
619 });
620 }
621
622 #[test]
623 fn write_atomic_sync_error() {
624 let dir = tempfile::tempdir().expect("tempdir");
625 let path = dir.path().join("file.txt");
626 with_failpoint(FAIL_WRITE_SYNC, || {
627 let err = write_atomic(&path, b"data").unwrap_err();
628 assert!(err.contains("failed to write temp file"));
629 });
630 }
631
632 #[test]
633 fn write_atomic_rename_error() {
634 let dir = tempfile::tempdir().expect("tempdir");
635 let path = dir.path().join("file.txt");
636 with_failpoint(FAIL_WRITE_RENAME, || {
637 let err = write_atomic(&path, b"data").unwrap_err();
638 assert!(err.contains("failed to replace"));
639 });
640 }
641
642 #[test]
643 fn copy_atomic_reads_source() {
644 with_failpoint_disabled(|| {
645 let dir = tempfile::tempdir().expect("tempdir");
646 let source = dir.path().join("source.txt");
647 let dest = dir.path().join("dest.txt");
648 fs::write(&source, "copy").expect("write");
649 copy_atomic(&source, &dest).unwrap();
650 assert_eq!(fs::read_to_string(&dest).unwrap(), "copy");
651 });
652 }
653
654 #[test]
655 fn copy_atomic_missing_source() {
656 let dir = tempfile::tempdir().expect("tempdir");
657 let source = dir.path().join("missing.txt");
658 let dest = dir.path().join("dest.txt");
659 let err = copy_atomic(&source, &dest).unwrap_err();
660 assert!(err.contains("failed to read metadata"));
661 }
662
663 #[test]
664 fn ensure_file_or_absent_errors_on_dir() {
665 let dir = tempfile::tempdir().expect("tempdir");
666 let err = ensure_file_or_absent(dir.path()).unwrap_err();
667 assert!(err.contains("exists and is not a file"));
668 }
669}