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}