Skip to main content

codex_ops/
storage.rs

1use std::env;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7#[derive(Debug, Clone, Eq, PartialEq)]
8pub struct StoragePaths {
9    pub codex_home: PathBuf,
10    pub helper_dir: PathBuf,
11    pub auth_file: PathBuf,
12    pub profile_store_dir: PathBuf,
13    pub account_history_file: PathBuf,
14    pub sessions_dir: PathBuf,
15}
16
17#[derive(Debug, Clone, Default, Eq, PartialEq)]
18pub struct StorageOptions {
19    pub codex_home: Option<PathBuf>,
20    pub auth_file: Option<PathBuf>,
21    pub profile_store_dir: Option<PathBuf>,
22    pub account_history_file: Option<PathBuf>,
23    pub sessions_dir: Option<PathBuf>,
24}
25
26pub fn default_codex_home() -> PathBuf {
27    let home = env::var_os("HOME")
28        .map(PathBuf::from)
29        .unwrap_or_else(|| PathBuf::from("."));
30    default_codex_home_from(env::var_os("CODEX_HOME").map(PathBuf::from), home)
31}
32
33pub fn default_codex_home_from(codex_home_env: Option<PathBuf>, home_dir: PathBuf) -> PathBuf {
34    codex_home_env.unwrap_or_else(|| home_dir.join(".codex"))
35}
36
37pub fn resolve_codex_home(options: &StorageOptions) -> PathBuf {
38    options
39        .codex_home
40        .clone()
41        .unwrap_or_else(default_codex_home)
42}
43
44pub fn resolve_codex_ops_dir(codex_home: impl AsRef<Path>) -> PathBuf {
45    codex_home.as_ref().join("codex-ops")
46}
47
48pub fn resolve_storage_paths(options: &StorageOptions) -> StoragePaths {
49    let codex_home = resolve_codex_home(options);
50    let helper_dir = resolve_codex_ops_dir(&codex_home);
51
52    StoragePaths {
53        auth_file: options
54            .auth_file
55            .clone()
56            .unwrap_or_else(|| codex_home.join("auth.json")),
57        profile_store_dir: options
58            .profile_store_dir
59            .clone()
60            .unwrap_or_else(|| helper_dir.join("auth-profiles")),
61        account_history_file: options
62            .account_history_file
63            .clone()
64            .unwrap_or_else(|| helper_dir.join("auth-account-history.json")),
65        sessions_dir: options
66            .sessions_dir
67            .clone()
68            .unwrap_or_else(|| codex_home.join("sessions")),
69        codex_home,
70        helper_dir,
71    }
72}
73
74pub fn write_sensitive_file(file_path: impl AsRef<Path>, content: &str) -> io::Result<()> {
75    let file_path = file_path.as_ref();
76    let parent = file_path.parent().unwrap_or_else(|| Path::new("."));
77    fs::create_dir_all(parent)?;
78    set_permissions_best_effort(parent, 0o700);
79
80    let temp_file = parent.join(format!(
81        ".{}.{}.tmp",
82        percent_encode(&temp_seed()),
83        percent_encode(
84            file_path
85                .file_name()
86                .and_then(|name| name.to_str())
87                .unwrap_or("codex-ops")
88        )
89    ));
90
91    let write_result = (|| {
92        fs::write(&temp_file, content)?;
93        set_permissions_best_effort(&temp_file, 0o600);
94        fs::rename(&temp_file, file_path)?;
95        set_permissions_best_effort(file_path, 0o600);
96        Ok(())
97    })();
98
99    if write_result.is_err() {
100        let _ = fs::remove_file(&temp_file);
101    }
102
103    write_result
104}
105
106pub fn percent_encode(value: &str) -> String {
107    let mut output = String::new();
108    for byte in value.bytes() {
109        let char = byte as char;
110        if char.is_ascii_alphanumeric()
111            || matches!(char, '-' | '_' | '.' | '!' | '~' | '*' | '\'' | '(' | ')')
112        {
113            output.push(char);
114        } else {
115            output.push_str(&format!("%{byte:02X}"));
116        }
117    }
118    output
119}
120
121pub fn path_to_string(path: impl AsRef<Path>) -> String {
122    path.as_ref().to_string_lossy().to_string()
123}
124
125fn temp_seed() -> String {
126    let millis = SystemTime::now()
127        .duration_since(UNIX_EPOCH)
128        .map(|duration| duration.as_millis())
129        .unwrap_or_default();
130    format!("{millis}-{}", std::process::id())
131}
132
133#[cfg(unix)]
134fn set_permissions_best_effort(path: &Path, mode: u32) {
135    use std::os::unix::fs::PermissionsExt;
136
137    let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
138}
139
140#[cfg(not(unix))]
141fn set_permissions_best_effort(_path: &Path, _mode: u32) {}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn default_codex_home_prefers_env_value() {
149        let result = default_codex_home_from(
150            Some(PathBuf::from("/tmp/codex")),
151            PathBuf::from("/home/user"),
152        );
153
154        assert_eq!(result, PathBuf::from("/tmp/codex"));
155    }
156
157    #[test]
158    fn default_codex_home_falls_back_to_home_dot_codex() {
159        let result = default_codex_home_from(None, PathBuf::from("/home/user"));
160
161        assert_eq!(result, PathBuf::from("/home/user/.codex"));
162    }
163
164    #[test]
165    fn resolves_default_helper_paths_under_codex_home() {
166        let paths = resolve_storage_paths(&StorageOptions {
167            codex_home: Some(PathBuf::from("/tmp/codex-home")),
168            ..StorageOptions::default()
169        });
170
171        assert_eq!(paths.auth_file, PathBuf::from("/tmp/codex-home/auth.json"));
172        assert_eq!(
173            paths.profile_store_dir,
174            PathBuf::from("/tmp/codex-home/codex-ops/auth-profiles")
175        );
176        assert_eq!(
177            paths.account_history_file,
178            PathBuf::from("/tmp/codex-home/codex-ops/auth-account-history.json")
179        );
180        assert_eq!(
181            paths.sessions_dir,
182            PathBuf::from("/tmp/codex-home/sessions")
183        );
184    }
185
186    #[test]
187    fn explicit_files_override_default_paths() {
188        let paths = resolve_storage_paths(&StorageOptions {
189            codex_home: Some(PathBuf::from("/tmp/codex-home")),
190            auth_file: Some(PathBuf::from("/tmp/auth.json")),
191            profile_store_dir: Some(PathBuf::from("/tmp/profiles")),
192            account_history_file: Some(PathBuf::from("/tmp/history.json")),
193            sessions_dir: Some(PathBuf::from("/tmp/sessions")),
194        });
195
196        assert_eq!(paths.auth_file, PathBuf::from("/tmp/auth.json"));
197        assert_eq!(paths.profile_store_dir, PathBuf::from("/tmp/profiles"));
198        assert_eq!(
199            paths.account_history_file,
200            PathBuf::from("/tmp/history.json")
201        );
202        assert_eq!(paths.sessions_dir, PathBuf::from("/tmp/sessions"));
203    }
204
205    #[test]
206    fn percent_encodes_like_encode_uri_component_for_profile_names() {
207        assert_eq!(percent_encode("account-a"), "account-a");
208        assert_eq!(percent_encode("account a/b"), "account%20a%2Fb");
209    }
210}