1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use directories::BaseDirs;
4use serde_json::Value;
5use std::env;
6use std::fs::{self, OpenOptions};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::sync::OnceLock;
10
11#[cfg(test)]
12use std::cell::Cell;
13#[cfg(test)]
14use std::sync::Mutex;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::{
18 COMMON_ERR_CREATE_DIR, COMMON_ERR_CREATE_PROFILES_DIR, COMMON_ERR_CREATE_TEMP,
19 COMMON_ERR_EXISTS_NOT_DIR, COMMON_ERR_EXISTS_NOT_FILE, COMMON_ERR_GET_TIME,
20 COMMON_ERR_INVALID_FILE_NAME, COMMON_ERR_READ_FILE, COMMON_ERR_READ_METADATA,
21 COMMON_ERR_REPLACE_FILE, COMMON_ERR_RESOLVE_HOME, COMMON_ERR_RESOLVE_PARENT,
22 COMMON_ERR_SET_PERMISSIONS, COMMON_ERR_SET_TEMP_PERMISSIONS, COMMON_ERR_WRITE_LOCK_FILE,
23 COMMON_ERR_WRITE_TEMP,
24};
25
26const UNEXPECTED_HTTP_BODY_MAX_BYTES: usize = 1000;
27
28pub struct Paths {
29 pub codex: PathBuf,
30 pub auth: PathBuf,
31 pub profiles: PathBuf,
32 pub profiles_index: PathBuf,
33 pub update_cache: PathBuf,
34 pub profiles_lock: PathBuf,
35}
36
37pub fn command_name() -> &'static str {
38 static COMMAND_NAME: OnceLock<String> = OnceLock::new();
39 COMMAND_NAME
40 .get_or_init(|| {
41 let env_value = env::var("CODEX_PROFILES_COMMAND").ok();
42 compute_command_name_from(env_value, env::args_os())
43 })
44 .as_str()
45}
46
47fn compute_command_name_from<I>(env_value: Option<String>, mut args: I) -> String
48where
49 I: Iterator<Item = std::ffi::OsString>,
50{
51 if let Some(value) = env_value {
52 let trimmed = value.trim();
53 if !trimmed.is_empty() {
54 return trimmed.to_string();
55 }
56 }
57 args.next()
58 .and_then(|arg| {
59 Path::new(&arg)
60 .file_name()
61 .and_then(|name| name.to_str())
62 .map(|name| name.to_string())
63 })
64 .filter(|name| !name.is_empty())
65 .unwrap_or_else(|| "codex-profiles".to_string())
66}
67
68pub fn package_command_name() -> &'static str {
69 "codex-profiles"
70}
71
72#[cfg(unix)]
73const FAIL_SET_PERMISSIONS: usize = 1;
74const FAIL_WRITE_OPEN: usize = 2;
75const FAIL_WRITE_WRITE: usize = 3;
76const FAIL_WRITE_PERMS: usize = 4;
77const FAIL_WRITE_SYNC: usize = 5;
78const FAIL_WRITE_RENAME: usize = 6;
79
80#[cfg(test)]
81thread_local! {
82 static FAILPOINT: Cell<usize> = const { Cell::new(0) };
83}
84#[cfg(test)]
85static FAILPOINT_LOCK: Mutex<()> = Mutex::new(());
86
87#[cfg(test)]
88fn maybe_fail(step: usize) -> std::io::Result<()> {
89 if FAILPOINT.with(|failpoint| failpoint.get()) == step {
90 return Err(std::io::Error::other("failpoint"));
91 }
92 Ok(())
93}
94
95#[cfg(not(test))]
96fn maybe_fail(_step: usize) -> std::io::Result<()> {
97 Ok(())
98}
99
100pub fn resolve_paths() -> Result<Paths, String> {
101 let home_dir = resolve_home_dir().ok_or_else(|| COMMON_ERR_RESOLVE_HOME.to_string())?;
102 let codex_dir = home_dir.join(".codex");
103 let auth = codex_dir.join("auth.json");
104 let profiles = codex_dir.join("profiles");
105 let profiles_index = profiles.join("profiles.json");
106 let update_cache = profiles.join("update.json");
107 let profiles_lock = profiles.join("profiles.lock");
108 Ok(Paths {
109 codex: codex_dir,
110 auth,
111 profiles,
112 profiles_index,
113 update_cache,
114 profiles_lock,
115 })
116}
117
118fn resolve_home_dir() -> Option<PathBuf> {
119 let codex_home = env::var_os("CODEX_PROFILES_HOME").map(PathBuf::from);
120 let base_home = BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
121 let home = env::var_os("HOME").map(PathBuf::from);
122 let userprofile = env::var_os("USERPROFILE").map(PathBuf::from);
123 let homedrive = env::var_os("HOMEDRIVE").map(PathBuf::from);
124 let homepath = env::var_os("HOMEPATH").map(PathBuf::from);
125 resolve_home_dir_with(
126 codex_home,
127 base_home,
128 home,
129 userprofile,
130 homedrive,
131 homepath,
132 )
133}
134
135fn resolve_home_dir_with(
136 codex_home: Option<PathBuf>,
137 base_home: Option<PathBuf>,
138 home: Option<PathBuf>,
139 userprofile: Option<PathBuf>,
140 homedrive: Option<PathBuf>,
141 homepath: Option<PathBuf>,
142) -> Option<PathBuf> {
143 if let Some(path) = non_empty_path(codex_home) {
144 return Some(path);
145 }
146 if let Some(path) = base_home {
147 return Some(path);
148 }
149 if let Some(path) = non_empty_path(home) {
150 return Some(path);
151 }
152 if let Some(path) = non_empty_path(userprofile) {
153 return Some(path);
154 }
155 match (homedrive, homepath) {
156 (Some(drive), Some(path)) => {
157 let mut out = drive;
158 out.push(path);
159 if out.as_os_str().is_empty() {
160 None
161 } else {
162 Some(out)
163 }
164 }
165 _ => None,
166 }
167}
168
169fn non_empty_path(path: Option<PathBuf>) -> Option<PathBuf> {
170 path.filter(|path| !path.as_os_str().is_empty())
171}
172
173pub fn ensure_paths(paths: &Paths) -> Result<(), String> {
174 if paths.profiles.exists() && !paths.profiles.is_dir() {
175 return Err(crate::msg1(
176 COMMON_ERR_EXISTS_NOT_DIR,
177 paths.profiles.display(),
178 ));
179 }
180
181 fs::create_dir_all(&paths.profiles).map_err(|err| {
182 crate::msg2(
183 COMMON_ERR_CREATE_PROFILES_DIR,
184 paths.profiles.display(),
185 err,
186 )
187 })?;
188
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::PermissionsExt;
192 let perms = fs::Permissions::from_mode(0o700);
193 if let Err(err) = set_profile_permissions(&paths.profiles, perms) {
194 return Err(crate::msg2(
195 COMMON_ERR_SET_PERMISSIONS,
196 paths.profiles.display(),
197 err,
198 ));
199 }
200 }
201
202 ensure_file_or_absent(&paths.profiles_index)?;
203 ensure_file_or_absent(&paths.update_cache)?;
204 ensure_file_or_absent(&paths.profiles_lock)?;
205
206 let mut options = OpenOptions::new();
207 options.create(true).append(true);
208 #[cfg(unix)]
209 {
210 use std::os::unix::fs::OpenOptionsExt;
211 options.mode(0o600);
212 }
213 options.open(&paths.profiles_lock).map_err(|err| {
214 crate::msg2(
215 COMMON_ERR_WRITE_LOCK_FILE,
216 paths.profiles_lock.display(),
217 err,
218 )
219 })?;
220
221 Ok(())
222}
223
224pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), String> {
225 let permissions = fs::metadata(path).ok().map(|meta| meta.permissions());
226 write_atomic_with_permissions(path, contents, permissions)
227}
228
229pub fn write_atomic_with_mode(path: &Path, contents: &[u8], mode: u32) -> Result<(), String> {
230 #[cfg(unix)]
231 {
232 use std::os::unix::fs::PermissionsExt;
233 let permissions = fs::Permissions::from_mode(mode);
234 write_atomic_with_permissions(path, contents, Some(permissions))
235 }
236 #[cfg(not(unix))]
237 {
238 let _ = mode;
239 write_atomic_with_permissions(path, contents, None)
240 }
241}
242
243pub fn write_atomic_private(path: &Path, contents: &[u8]) -> Result<(), String> {
244 #[cfg(unix)]
245 {
246 write_atomic_with_mode(path, contents, 0o600)
247 }
248 #[cfg(not(unix))]
249 {
250 write_atomic(path, contents)
251 }
252}
253
254fn write_atomic_with_permissions(
255 path: &Path,
256 contents: &[u8],
257 permissions: Option<fs::Permissions>,
258) -> Result<(), String> {
259 let parent = path
260 .parent()
261 .ok_or_else(|| crate::msg1(COMMON_ERR_RESOLVE_PARENT, path.display()))?;
262 if !parent.as_os_str().is_empty() {
263 fs::create_dir_all(parent)
264 .map_err(|err| crate::msg2(COMMON_ERR_CREATE_DIR, parent.display(), err))?;
265 }
266
267 let file_name = path
268 .file_name()
269 .and_then(|name| name.to_str())
270 .ok_or_else(|| crate::msg1(COMMON_ERR_INVALID_FILE_NAME, path.display()))?;
271 let pid = std::process::id();
272 let mut attempt = 0u32;
273 loop {
274 let nanos = SystemTime::now()
275 .duration_since(UNIX_EPOCH)
276 .map_err(|err| crate::msg1(COMMON_ERR_GET_TIME, err))?
277 .as_nanos();
278 let tmp_name = format!(".{file_name}.tmp-{pid}-{nanos}-{attempt}");
279 let tmp_path = parent.join(tmp_name);
280 let mut options = OpenOptions::new();
281 options.write(true).create_new(true);
282 #[cfg(unix)]
283 if let Some(permissions) = permissions.as_ref() {
284 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
285 options.mode(permissions.mode());
286 }
287 let mut tmp_file = match options.open(&tmp_path).and_then(|file| {
288 maybe_fail(FAIL_WRITE_OPEN)?;
289 Ok(file)
290 }) {
291 Ok(file) => file,
292 Err(err) => {
293 attempt += 1;
294 if attempt < 5 {
295 continue;
296 }
297 return Err(crate::msg2(COMMON_ERR_CREATE_TEMP, path.display(), err));
298 }
299 };
300
301 maybe_fail(FAIL_WRITE_WRITE)
302 .and_then(|_| tmp_file.write_all(contents))
303 .map_err(|err| crate::msg2(COMMON_ERR_WRITE_TEMP, path.display(), err))?;
304
305 if let Some(permissions) = permissions {
306 maybe_fail(FAIL_WRITE_PERMS)
307 .and_then(|_| fs::set_permissions(&tmp_path, permissions))
308 .map_err(|err| crate::msg2(COMMON_ERR_SET_TEMP_PERMISSIONS, path.display(), err))?;
309 }
310
311 maybe_fail(FAIL_WRITE_SYNC)
312 .and_then(|_| tmp_file.sync_all())
313 .map_err(|err| crate::msg2(COMMON_ERR_WRITE_TEMP, path.display(), err))?;
314
315 let rename_result = maybe_fail(FAIL_WRITE_RENAME).and_then(|_| fs::rename(&tmp_path, path));
316 match rename_result {
317 Ok(()) => return Ok(()),
318 Err(err) => {
319 #[cfg(windows)]
320 {
321 if path.exists() {
322 let _ = fs::remove_file(path);
323 }
324 if fs::rename(&tmp_path, path).is_ok() {
325 return Ok(());
326 }
327 }
328 let _ = fs::remove_file(&tmp_path);
329 return Err(crate::msg2(COMMON_ERR_REPLACE_FILE, path.display(), err));
330 }
331 }
332 }
333}
334
335pub fn copy_atomic(source: &Path, dest: &Path) -> Result<(), String> {
336 let permissions = fs::metadata(source)
337 .map_err(|err| crate::msg2(COMMON_ERR_READ_METADATA, source.display(), err))?
338 .permissions();
339 let contents =
340 fs::read(source).map_err(|err| crate::msg2(COMMON_ERR_READ_FILE, source.display(), err))?;
341 write_atomic_with_permissions(dest, &contents, Some(permissions))
342}
343
344fn ensure_file_or_absent(path: &Path) -> Result<(), String> {
345 if path.exists() && !path.is_file() {
346 return Err(crate::msg1(COMMON_ERR_EXISTS_NOT_FILE, path.display()));
347 }
348 Ok(())
349}
350
351#[derive(Clone, Debug)]
352pub struct UnexpectedHttpError {
353 status: u16,
354 status_text: Option<String>,
355 body: String,
356 url: Option<String>,
357 cf_ray: Option<String>,
358 request_id: Option<String>,
359 identity_authorization_error: Option<String>,
360 identity_error_code: Option<String>,
361}
362
363impl UnexpectedHttpError {
364 pub fn from_ureq_response(
365 response: ureq::http::Response<ureq::Body>,
366 url: Option<&str>,
367 ) -> Self {
368 let status = response.status();
369 let request_id = header_value(&response, "x-request-id")
370 .or_else(|| header_value(&response, "x-oai-request-id"));
371 let cf_ray = header_value(&response, "cf-ray");
372 let identity_authorization_error = header_value(&response, "x-openai-authorization-error");
373 let identity_error_code = header_value(&response, "x-error-json")
374 .and_then(|value| decode_identity_error_code(&value));
375 let body = response.into_body().read_to_string().unwrap_or_default();
376 Self {
377 status: status.as_u16(),
378 status_text: status.canonical_reason().map(str::to_string),
379 body,
380 url: url.map(str::to_string),
381 cf_ray,
382 request_id,
383 identity_authorization_error,
384 identity_error_code,
385 }
386 }
387
388 pub fn status_code(&self) -> u16 {
389 self.status
390 }
391
392 fn status_label(&self) -> String {
393 match self.status_text.as_deref() {
394 Some(text) if !text.is_empty() => format!("{} {}", self.status, text),
395 _ => self.status.to_string(),
396 }
397 }
398
399 fn extract_error_message(&self) -> Option<String> {
400 let json = self.parsed_body_json()?;
401 Self::extract_error_message_from_json(&json)
402 }
403
404 fn parsed_body_json(&self) -> Option<Value> {
405 serde_json::from_str::<Value>(&self.body).ok()
406 }
407
408 fn extract_error_message_from_json(json: &Value) -> Option<String> {
409 let message = json
410 .get("error")
411 .and_then(|error| error.get("message"))
412 .and_then(Value::as_str)?
413 .trim();
414 if message.is_empty() {
415 None
416 } else {
417 Some(message.to_string())
418 }
419 }
420
421 fn display_body(&self) -> String {
422 if let Some(message) = self.extract_error_message() {
423 return message;
424 }
425 let trimmed = self.body.trim();
426 if trimmed.is_empty() {
427 return "Unknown error".to_string();
428 }
429 truncate_with_ellipsis(trimmed, UNEXPECTED_HTTP_BODY_MAX_BYTES)
430 }
431
432 fn plain_body(&self) -> String {
433 let parsed = self.parsed_body_json();
434 if let Some(message) = parsed
435 .as_ref()
436 .and_then(Self::extract_error_message_from_json)
437 .or_else(|| parsed.as_ref().and_then(Self::extract_detail_message))
438 {
439 return sanitize_for_terminal(&message).trim().to_string();
440 }
441 sanitize_for_terminal(&self.display_body())
442 .trim()
443 .to_string()
444 }
445
446 fn extract_detail_message(json: &Value) -> Option<String> {
447 json.get("detail")
448 .and_then(|detail| detail.get("message"))
449 .and_then(Value::as_str)
450 .map(str::trim)
451 .filter(|message| !message.is_empty())
452 .map(str::to_string)
453 }
454
455 fn extract_detail_code(&self) -> Option<String> {
456 let json = self.parsed_body_json()?;
457
458 json.get("detail")
459 .and_then(|detail| detail.get("code"))
460 .and_then(Value::as_str)
461 .or_else(|| {
462 json.get("error")
463 .and_then(|error| error.get("code"))
464 .and_then(Value::as_str)
465 })
466 .map(str::to_string)
467 }
468
469 fn plain_summary(&self) -> String {
470 sanitize_for_terminal(
471 &self
472 .extract_detail_code()
473 .unwrap_or_else(|| self.plain_body()),
474 )
475 .trim()
476 .to_string()
477 }
478
479 fn append_debug_context(&self, message: &mut String) {
480 if let Some(url) = &self.url {
481 message.push_str(&format!(", url: {url}"));
482 }
483 if let Some(cf_ray) = &self.cf_ray {
484 message.push_str(&format!(", cf-ray: {cf_ray}"));
485 }
486 if let Some(id) = &self.request_id {
487 message.push_str(&format!(", request id: {id}"));
488 }
489 if let Some(auth_error) = &self.identity_authorization_error {
490 message.push_str(&format!(", auth error: {auth_error}"));
491 }
492 if let Some(error_code) = &self.identity_error_code {
493 message.push_str(&format!(", auth error code: {error_code}"));
494 }
495 }
496
497 fn append_debug_context_lines(&self, lines: &mut Vec<String>) {
498 if let Some(url) = &self.url {
499 lines.push(format!("URL: {}", sanitize_for_terminal(url).trim()));
500 }
501 if let Some(cf_ray) = &self.cf_ray {
502 lines.push(format!("CF-Ray: {}", sanitize_for_terminal(cf_ray).trim()));
503 }
504 if let Some(id) = &self.request_id {
505 lines.push(format!("Request ID: {}", sanitize_for_terminal(id).trim()));
506 }
507 if let Some(auth_error) = &self.identity_authorization_error {
508 lines.push(format!(
509 "Auth Error: {}",
510 sanitize_for_terminal(auth_error).trim()
511 ));
512 }
513 if let Some(error_code) = &self.identity_error_code {
514 lines.push(format!(
515 "Auth Error Code: {}",
516 sanitize_for_terminal(error_code).trim()
517 ));
518 }
519 }
520
521 pub fn plain_message(&self) -> String {
522 let mut lines = vec![self.plain_summary()];
523 lines.push(format!("unexpected status {}", self.status_label()));
524 self.append_debug_context_lines(&mut lines);
525 lines.join("\n")
526 }
527}
528
529impl std::fmt::Display for UnexpectedHttpError {
530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 let mut message = format!(
532 "unexpected status {}: {}",
533 self.status_label(),
534 self.display_body()
535 );
536 self.append_debug_context(&mut message);
537 f.write_str(&message)
538 }
539}
540
541impl std::error::Error for UnexpectedHttpError {}
542
543fn header_value(response: &ureq::http::Response<ureq::Body>, name: &str) -> Option<String> {
544 response
545 .headers()
546 .get(name)
547 .and_then(|value| value.to_str().ok())
548 .map(str::to_string)
549}
550
551fn decode_identity_error_code(encoded: &str) -> Option<String> {
552 let decoded = STANDARD.decode(encoded.trim()).ok()?;
553 let json = serde_json::from_slice::<Value>(&decoded).ok()?;
554 json.get("error")
555 .and_then(|error| error.get("code"))
556 .and_then(Value::as_str)
557 .map(str::to_string)
558}
559
560fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String {
561 if text.len() <= max_bytes {
562 return text.to_string();
563 }
564 let mut cut = max_bytes;
565 while !text.is_char_boundary(cut) {
566 cut = cut.saturating_sub(1);
567 }
568 let mut truncated = text[..cut].to_string();
569 truncated.push_str("...");
570 truncated
571}
572
573pub(crate) fn sanitize_for_terminal(input: &str) -> String {
574 let mut out = String::with_capacity(input.len());
575 let bytes = input.as_bytes();
576 let mut i = 0usize;
577
578 while i < bytes.len() {
579 if bytes[i] == 0x1b {
580 i += 1;
581 if i >= bytes.len() {
582 break;
583 }
584 match bytes[i] {
585 b'[' => {
586 i += 1;
587 while i < bytes.len() {
588 let b = bytes[i];
589 i += 1;
590 if (0x40..=0x7e).contains(&b) {
591 break;
592 }
593 }
594 }
595 b']' => {
596 i += 1;
597 while i < bytes.len() {
598 if bytes[i] == 0x07 {
599 i += 1;
600 break;
601 }
602 if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
603 i += 2;
604 break;
605 }
606 i += 1;
607 }
608 }
609 _ => {
610 i += 1;
611 }
612 }
613 continue;
614 }
615
616 let ch = input[i..].chars().next().expect("valid utf-8 char");
617 if !ch.is_control() || matches!(ch, '\n' | '\t') {
618 out.push(ch);
619 }
620 i += ch.len_utf8();
621 }
622
623 out
624}
625
626#[cfg(unix)]
627fn set_profile_permissions(path: &Path, perms: fs::Permissions) -> std::io::Result<()> {
628 maybe_fail(FAIL_SET_PERMISSIONS)?;
629 fs::set_permissions(path, perms)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::test_utils::{make_paths, spawn_server};
636 use std::ffi::OsString;
637 use std::fs;
638 use ureq::Agent;
639
640 fn with_failpoint<F: FnOnce()>(step: usize, f: F) {
641 let _guard = FAILPOINT_LOCK.lock().unwrap();
642 let prev = FAILPOINT.with(|failpoint| {
643 let prev = failpoint.get();
644 failpoint.set(step);
645 prev
646 });
647 f();
648 FAILPOINT.with(|failpoint| failpoint.set(prev));
649 }
650
651 fn with_failpoint_disabled<F: FnOnce()>(f: F) {
652 let _guard = FAILPOINT_LOCK.lock().unwrap();
653 let prev = FAILPOINT.with(|failpoint| {
654 let prev = failpoint.get();
655 failpoint.set(0);
656 prev
657 });
658 f();
659 FAILPOINT.with(|failpoint| failpoint.set(prev));
660 }
661
662 fn http_response(status: &str, headers: &[(&str, &str)], body: &str) -> String {
663 let mut response = format!("HTTP/1.1 {status}\r\n");
664 for (name, value) in headers {
665 response.push_str(name);
666 response.push_str(": ");
667 response.push_str(value);
668 response.push_str("\r\n");
669 }
670 response.push_str(&format!("Content-Length: {}\r\n\r\n{}", body.len(), body));
671 response
672 }
673
674 fn fetch_response(url: &str) -> ureq::http::Response<ureq::Body> {
675 let agent: Agent = ureq::Agent::config_builder()
676 .http_status_as_error(false)
677 .build()
678 .into();
679 agent.get(url).call().unwrap()
680 }
681
682 #[test]
683 fn compute_command_name_uses_env() {
684 let name = compute_command_name_from(Some("mycmd".to_string()), Vec::new().into_iter());
685 assert_eq!(name, "mycmd");
686 }
687
688 #[test]
689 fn compute_command_name_uses_args() {
690 let args = vec![OsString::from("/usr/bin/codex-profiles")];
691 let name = compute_command_name_from(None, args.into_iter());
692 assert_eq!(name, "codex-profiles");
693 }
694
695 #[test]
696 fn compute_command_name_ignores_blank_env() {
697 let args = vec![OsString::from("/usr/local/bin/custom")];
698 let name = compute_command_name_from(Some(" ".to_string()), args.into_iter());
699 assert_eq!(name, "custom");
700 }
701
702 #[test]
703 fn compute_command_name_fallback() {
704 let name = compute_command_name_from(None, Vec::new().into_iter());
705 assert_eq!(name, "codex-profiles");
706 }
707
708 #[test]
709 fn resolve_home_dir_prefers_codex_env() {
710 let out = resolve_home_dir_with(
711 Some(PathBuf::from("/tmp/codex")),
712 Some(PathBuf::from("/tmp/base")),
713 Some(PathBuf::from("/tmp/home")),
714 None,
715 None,
716 None,
717 )
718 .unwrap();
719 assert_eq!(out, PathBuf::from("/tmp/codex"));
720 }
721
722 #[test]
723 fn resolve_home_dir_uses_base_dirs() {
724 let out = resolve_home_dir_with(
725 None,
726 Some(PathBuf::from("/tmp/base")),
727 None,
728 None,
729 None,
730 None,
731 )
732 .unwrap();
733 assert_eq!(out, PathBuf::from("/tmp/base"));
734 }
735
736 #[test]
737 fn resolve_home_dir_falls_back() {
738 let out = resolve_home_dir_with(
739 Some(PathBuf::from("")),
740 None,
741 Some(PathBuf::from("/tmp/home")),
742 Some(PathBuf::from("/tmp/user")),
743 Some(PathBuf::from("C:")),
744 Some(PathBuf::from("/Users")),
745 )
746 .unwrap();
747 assert_eq!(out, PathBuf::from("/tmp/home"));
748 }
749
750 #[test]
751 fn unexpected_http_error_plain_message_formats_multiline_for_unknown_body() {
752 let err = UnexpectedHttpError {
753 status: 402,
754 status_text: Some("Payment Required".to_string()),
755 body: r#"{"detail":{"code":"mystery_error"}}"#.to_string(),
756 url: Some("https://example.com/backend-api/wham/usage".to_string()),
757 cf_ray: Some("ray-123".to_string()),
758 request_id: Some("req-123".to_string()),
759 identity_authorization_error: None,
760 identity_error_code: None,
761 };
762
763 assert_eq!(
764 err.plain_message(),
765 concat!(
766 "mystery_error\n",
767 "unexpected status 402 Payment Required\n",
768 "URL: https://example.com/backend-api/wham/usage\n",
769 "CF-Ray: ray-123\n",
770 "Request ID: req-123"
771 )
772 );
773 }
774
775 #[test]
776 fn unexpected_http_error_plain_message_formats_multiline_for_known_code() {
777 let err = UnexpectedHttpError {
778 status: 402,
779 status_text: Some("Payment Required".to_string()),
780 body: r#"{"detail":{"code":"deactivated_workspace"}}"#.to_string(),
781 url: Some("https://example.com/backend-api/wham/usage".to_string()),
782 cf_ray: Some("ray-456".to_string()),
783 request_id: Some("req-456".to_string()),
784 identity_authorization_error: None,
785 identity_error_code: None,
786 };
787
788 assert_eq!(
789 err.plain_message(),
790 concat!(
791 "deactivated_workspace\n",
792 "unexpected status 402 Payment Required\n",
793 "URL: https://example.com/backend-api/wham/usage\n",
794 "CF-Ray: ray-456\n",
795 "Request ID: req-456"
796 )
797 );
798 }
799
800 #[test]
801 fn unexpected_http_error_plain_message_sanitizes_terminal_control_sequences() {
802 let err = UnexpectedHttpError {
803 status: 500,
804 status_text: Some("Internal Server Error".to_string()),
805 body: "\u{1b}]8;;https://evil\u{7}oops\u{1b}]8;;\u{7}".to_string(),
806 url: Some("https://example.com/\u{1b}[31musage\u{1b}[0m".to_string()),
807 cf_ray: Some("ray-\u{7}123".to_string()),
808 request_id: Some("req-\u{1b}[2K456".to_string()),
809 identity_authorization_error: None,
810 identity_error_code: None,
811 };
812
813 let plain = err.plain_message();
814 assert!(!plain.contains('\u{1b}'));
815 assert!(!plain.contains('\u{7}'));
816 assert!(plain.contains("oops"));
817 assert!(plain.contains("URL: https://example.com/usage"));
818 assert!(plain.contains("CF-Ray: ray-123"));
819 assert!(plain.contains("Request ID: req-456"));
820 }
821
822 #[test]
823 fn unexpected_http_error_uses_x_oai_request_id_fallback() {
824 let response = http_response(
825 "400 Bad Request",
826 &[("x-oai-request-id", "req-fallback")],
827 "plain body",
828 );
829 let url = spawn_server(response);
830 let response = fetch_response(&url);
831
832 let err = UnexpectedHttpError::from_ureq_response(response, Some(&url));
833 let rendered = err.to_string();
834 assert!(rendered.contains("request id: req-fallback"));
835 }
836
837 #[test]
838 fn unexpected_http_error_surfaces_auth_debug_headers() {
839 let error_json = STANDARD.encode(r#"{"error":{"code":"deactivated_workspace"}}"#);
840 let response = http_response(
841 "401 Unauthorized",
842 &[
843 ("x-openai-authorization-error", "expired session"),
844 ("x-error-json", &error_json),
845 ],
846 "plain body",
847 );
848 let url = spawn_server(response);
849 let response = fetch_response(&url);
850
851 let err = UnexpectedHttpError::from_ureq_response(response, Some(&url));
852 let rendered = err.to_string();
853 assert!(rendered.contains("auth error: expired session"));
854 assert!(rendered.contains("auth error code: deactivated_workspace"));
855 }
856
857 #[test]
858 fn unexpected_http_error_plain_message_prefers_detail_message() {
859 let err = UnexpectedHttpError {
860 status: 402,
861 status_text: Some("Payment Required".to_string()),
862 body: r#"{"detail":{"message":"Workspace deactivated by owner"}}"#.to_string(),
863 url: None,
864 cf_ray: None,
865 request_id: None,
866 identity_authorization_error: None,
867 identity_error_code: None,
868 };
869
870 assert_eq!(
871 err.plain_message(),
872 "Workspace deactivated by owner\nunexpected status 402 Payment Required"
873 );
874 }
875
876 #[test]
877 fn unexpected_http_error_truncates_long_raw_body() {
878 let body = "x".repeat(1205);
879 let err = UnexpectedHttpError {
880 status: 500,
881 status_text: Some("Internal Server Error".to_string()),
882 body,
883 url: None,
884 cf_ray: None,
885 request_id: None,
886 identity_authorization_error: None,
887 identity_error_code: None,
888 };
889
890 let rendered = err.to_string();
891 assert!(rendered.starts_with("unexpected status 500 Internal Server Error: "));
892 assert!(rendered.ends_with("..."));
893 assert!(rendered.len() < 1100);
894 }
895
896 #[test]
897 fn unexpected_http_error_uses_non_json_body_verbatim() {
898 let response = http_response("502 Bad Gateway", &[], "gateway exploded");
899 let url = spawn_server(response);
900 let response = fetch_response(&url);
901
902 let err = UnexpectedHttpError::from_ureq_response(response, Some(&url));
903 let rendered = err.to_string();
904 assert!(rendered.contains("unexpected status 502 Bad Gateway: gateway exploded"));
905 }
906
907 #[test]
908 fn resolve_home_dir_uses_userprofile() {
909 let out = resolve_home_dir_with(
910 None,
911 None,
912 None,
913 Some(PathBuf::from("/tmp/user")),
914 None,
915 None,
916 )
917 .unwrap();
918 assert_eq!(out, PathBuf::from("/tmp/user"));
919 }
920
921 #[test]
922 fn resolve_home_dir_uses_drive() {
923 let out = resolve_home_dir_with(
924 None,
925 None,
926 None,
927 None,
928 Some(PathBuf::from("C:")),
929 Some(PathBuf::from("Users")),
930 )
931 .unwrap();
932 assert_eq!(out, PathBuf::from("C:/Users"));
933 }
934
935 #[test]
936 fn resolve_home_dir_none_when_empty() {
937 assert!(resolve_home_dir_with(None, None, None, None, None, None).is_none());
938 }
939
940 #[test]
941 fn resolve_home_dir_ignores_empty_values() {
942 assert!(
943 resolve_home_dir_with(None, None, Some(PathBuf::from("")), None, None, None,).is_none()
944 );
945 assert!(
946 resolve_home_dir_with(None, None, None, Some(PathBuf::from("")), None, None,).is_none()
947 );
948 assert!(
949 resolve_home_dir_with(
950 None,
951 None,
952 None,
953 None,
954 Some(PathBuf::from("")),
955 Some(PathBuf::from("")),
956 )
957 .is_none()
958 );
959 }
960
961 #[test]
962 fn ensure_paths_errors_when_profiles_is_file() {
963 let dir = tempfile::tempdir().expect("tempdir");
964 let profiles = dir.path().join("profiles");
965 fs::write(&profiles, "not a dir").expect("write");
966 let paths = make_paths(dir.path());
967 let err = ensure_paths(&paths).unwrap_err();
968 assert!(err.contains("not a directory"));
969 }
970
971 #[cfg(unix)]
972 #[test]
973 fn ensure_paths_errors_when_unwritable() {
974 use std::os::unix::fs::PermissionsExt;
975 let dir = tempfile::tempdir().expect("tempdir");
976 let locked = dir.path().join("locked");
977 fs::create_dir_all(&locked).expect("create");
978 fs::set_permissions(&locked, fs::Permissions::from_mode(0o400)).expect("chmod");
979 let profiles = locked.join("profiles");
980 let mut paths = make_paths(dir.path());
981 paths.profiles = profiles.clone();
982 paths.profiles_index = profiles.join("profiles.json");
983 paths.profiles_lock = profiles.join("profiles.lock");
984 let err = ensure_paths(&paths).unwrap_err();
985 assert!(err.contains("Cannot create profiles directory"));
986 }
987
988 #[cfg(unix)]
989 #[test]
990 fn ensure_paths_permissions_error() {
991 let dir = tempfile::tempdir().expect("tempdir");
992 let paths = make_paths(dir.path());
993 with_failpoint(FAIL_SET_PERMISSIONS, || {
994 let err = ensure_paths(&paths).unwrap_err();
995 assert!(err.contains("Cannot set permissions"));
996 });
997 }
998
999 #[cfg(unix)]
1000 #[test]
1001 fn ensure_paths_profiles_lock_open_error() {
1002 use std::os::unix::fs::PermissionsExt;
1003 let dir = tempfile::tempdir().expect("tempdir");
1004 let profiles = dir.path().join("profiles");
1005 fs::create_dir_all(&profiles).expect("create");
1006 let lock = profiles.join("profiles.lock");
1007 fs::write(&lock, "").expect("write lock");
1008 fs::set_permissions(&lock, fs::Permissions::from_mode(0o400)).expect("chmod");
1009 let mut paths = make_paths(dir.path());
1010 paths.profiles_lock = lock.clone();
1011 let err = ensure_paths(&paths).unwrap_err();
1012 assert!(err.contains("Cannot write profiles lock file"));
1013 }
1014
1015 #[test]
1016 fn write_atomic_success() {
1017 with_failpoint_disabled(|| {
1018 let dir = tempfile::tempdir().expect("tempdir");
1019 let path = dir.path().join("file.txt");
1020 write_atomic(&path, b"hello").unwrap();
1021 assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
1022 });
1023 }
1024
1025 #[test]
1026 fn write_atomic_invalid_parent() {
1027 let err = write_atomic(Path::new(""), b"hi").unwrap_err();
1028 assert!(err.contains("parent directory"));
1029 }
1030
1031 #[test]
1032 fn write_atomic_invalid_filename() {
1033 let err = write_atomic(Path::new("/"), b"hi").unwrap_err();
1034 assert!(err.contains("invalid file name") || err.contains("parent directory"));
1035 }
1036
1037 #[test]
1038 fn write_atomic_create_dir_error() {
1039 let dir = tempfile::tempdir().expect("tempdir");
1040 let blocker = dir.path().join("blocker");
1041 fs::write(&blocker, "file").expect("write");
1042 let path = blocker.join("child.txt");
1043 let err = write_atomic(&path, b"data").unwrap_err();
1044 assert!(err.contains("Cannot create directory"));
1045 }
1046
1047 #[test]
1048 fn write_atomic_open_error() {
1049 let dir = tempfile::tempdir().expect("tempdir");
1050 let path = dir.path().join("file.txt");
1051 with_failpoint(FAIL_WRITE_OPEN, || {
1052 let err = write_atomic(&path, b"data").unwrap_err();
1053 assert!(err.contains("Failed to create temp file"));
1054 });
1055 }
1056
1057 #[test]
1058 fn write_atomic_write_error() {
1059 let dir = tempfile::tempdir().expect("tempdir");
1060 let path = dir.path().join("file.txt");
1061 with_failpoint(FAIL_WRITE_WRITE, || {
1062 let err = write_atomic(&path, b"data").unwrap_err();
1063 assert!(err.contains("Failed to write temp file"));
1064 });
1065 }
1066
1067 #[test]
1068 fn write_atomic_permissions_error() {
1069 let dir = tempfile::tempdir().expect("tempdir");
1070 let path = dir.path().join("file.txt");
1071 with_failpoint(FAIL_WRITE_PERMS, || {
1072 let err = write_atomic_with_mode(&path, b"data", 0o600).unwrap_err();
1073 assert!(err.contains("Failed to set temp file permissions"));
1074 });
1075 }
1076
1077 #[cfg(unix)]
1078 #[test]
1079 fn write_atomic_with_mode_creates_private_file() {
1080 use std::os::unix::fs::PermissionsExt;
1081
1082 let dir = tempfile::tempdir().expect("tempdir");
1083 let path = dir.path().join("file.txt");
1084
1085 write_atomic_with_mode(&path, b"data", 0o600).unwrap();
1086
1087 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1088 assert_eq!(mode, 0o600);
1089 }
1090
1091 #[test]
1092 fn write_atomic_sync_error() {
1093 let dir = tempfile::tempdir().expect("tempdir");
1094 let path = dir.path().join("file.txt");
1095 with_failpoint(FAIL_WRITE_SYNC, || {
1096 let err = write_atomic(&path, b"data").unwrap_err();
1097 assert!(err.contains("Failed to write temp file"));
1098 });
1099 }
1100
1101 #[test]
1102 fn write_atomic_rename_error() {
1103 let dir = tempfile::tempdir().expect("tempdir");
1104 let path = dir.path().join("file.txt");
1105 with_failpoint(FAIL_WRITE_RENAME, || {
1106 let err = write_atomic(&path, b"data").unwrap_err();
1107 assert!(err.contains("Failed to replace"));
1108 });
1109 }
1110
1111 #[test]
1112 fn copy_atomic_reads_source() {
1113 with_failpoint_disabled(|| {
1114 let dir = tempfile::tempdir().expect("tempdir");
1115 let source = dir.path().join("source.txt");
1116 let dest = dir.path().join("dest.txt");
1117 fs::write(&source, "copy").expect("write");
1118 copy_atomic(&source, &dest).unwrap();
1119 assert_eq!(fs::read_to_string(&dest).unwrap(), "copy");
1120 });
1121 }
1122
1123 #[test]
1124 fn copy_atomic_missing_source() {
1125 let dir = tempfile::tempdir().expect("tempdir");
1126 let source = dir.path().join("missing.txt");
1127 let dest = dir.path().join("dest.txt");
1128 let err = copy_atomic(&source, &dest).unwrap_err();
1129 assert!(err.contains("Failed to read metadata"));
1130 }
1131
1132 #[test]
1133 fn ensure_file_or_absent_errors_on_dir() {
1134 let dir = tempfile::tempdir().expect("tempdir");
1135 let err = ensure_file_or_absent(dir.path()).unwrap_err();
1136 assert!(err.contains("exists and is not a file"));
1137 }
1138}