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}