Skip to main content

codex_profiles/
common.rs

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}