Skip to main content

codex_profiles/
common.rs

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}