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