coproxy 0.6.1

OpenAI-compatible API proxy backed by GitHub Copilot
Documentation
use anyhow::Context;
use chrono::{DateTime, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Clone, Debug)]
pub struct TokenStore {
    root: PathBuf,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GhcpTokenRecord {
    pub token: String,
    pub expires_at: i64,
    pub api_endpoint: String,
}

#[derive(Debug)]
pub struct TokenStatus {
    pub github_token_cached: bool,
    pub ghcp_token_cached: bool,
    pub ghcp_expires_at: Option<DateTime<Utc>>,
}

impl TokenStore {
    pub fn new(state_dir_override: Option<PathBuf>) -> anyhow::Result<Self> {
        let root = state_dir_override
            .unwrap_or_else(default_state_dir)
            .join("auth");
        std::fs::create_dir_all(&root)
            .with_context(|| format!("failed creating state dir at {}", root.display()))?;
        set_dir_permissions(&root)?;
        Ok(Self { root })
    }

    pub fn root_dir(&self) -> &Path {
        &self.root
    }

    pub async fn load_github_token(&self) -> anyhow::Result<Option<String>> {
        read_text_file(self.github_token_path()).await
    }

    pub async fn save_github_token(&self, token: &str) -> anyhow::Result<()> {
        write_file_secure(self.github_token_path(), token).await
    }

    pub async fn delete_github_token(&self) -> anyhow::Result<()> {
        delete_if_exists(self.github_token_path()).await
    }

    pub async fn load_ghcp_token(&self) -> anyhow::Result<Option<GhcpTokenRecord>> {
        let Some(raw) = read_text_file(self.ghcp_token_path()).await? else {
            return Ok(None);
        };
        let parsed = serde_json::from_str::<GhcpTokenRecord>(&raw)
            .with_context(|| "failed parsing cached GHCP token JSON")?;
        Ok(Some(parsed))
    }

    pub async fn save_ghcp_token(&self, token: &GhcpTokenRecord) -> anyhow::Result<()> {
        let json = serde_json::to_string_pretty(token)?;
        write_file_secure(self.ghcp_token_path(), &json).await
    }

    pub async fn delete_ghcp_token(&self) -> anyhow::Result<()> {
        delete_if_exists(self.ghcp_token_path()).await
    }

    pub async fn clear_all(&self) -> anyhow::Result<()> {
        self.delete_ghcp_token().await?;
        self.delete_github_token().await?;
        Ok(())
    }

    pub async fn status(&self) -> anyhow::Result<TokenStatus> {
        let github = self.load_github_token().await?.is_some();
        let ghcp = self.load_ghcp_token().await?;
        let ghcp_expires_at = ghcp
            .as_ref()
            .and_then(|token| DateTime::<Utc>::from_timestamp(token.expires_at, 0));
        Ok(TokenStatus {
            github_token_cached: github,
            ghcp_token_cached: ghcp.is_some(),
            ghcp_expires_at,
        })
    }

    fn github_token_path(&self) -> PathBuf {
        self.root.join("github-access-token")
    }

    fn ghcp_token_path(&self) -> PathBuf {
        self.root.join("ghcp-token.json")
    }
}

fn default_state_dir() -> PathBuf {
    ProjectDirs::from("", "", "coproxy")
        .map(|dirs| {
            dirs.state_dir()
                .map(Path::to_path_buf)
                .unwrap_or_else(|| dirs.config_dir().to_path_buf())
        })
        .unwrap_or_else(|| {
            let user = std::env::var("USER")
                .or_else(|_| std::env::var("USERNAME"))
                .unwrap_or_else(|_| "unknown".to_string());
            std::env::temp_dir().join(format!("coproxy-{user}"))
        })
}

#[cfg(unix)]
fn set_dir_permissions(path: &Path) -> anyhow::Result<()> {
    use std::os::unix::fs::PermissionsExt;

    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
        .with_context(|| format!("failed setting permissions on {}", path.display()))
}

#[cfg(not(unix))]
fn set_dir_permissions(_path: &Path) -> anyhow::Result<()> {
    Ok(())
}

async fn read_text_file(path: PathBuf) -> anyhow::Result<Option<String>> {
    let raw = match tokio::fs::read_to_string(&path).await {
        Ok(data) => data,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => {
            return Err(err).with_context(|| format!("failed reading {}", path.display()));
        }
    };

    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    Ok(Some(trimmed.to_string()))
}

async fn delete_if_exists(path: PathBuf) -> anyhow::Result<()> {
    match tokio::fs::remove_file(&path).await {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err).with_context(|| format!("failed deleting {}", path.display())),
    }
}

async fn write_file_secure(path: PathBuf, content: &str) -> anyhow::Result<()> {
    let content = content.to_string();
    tokio::task::spawn_blocking(move || {
        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

            let mut file = std::fs::OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(true)
                .mode(0o600)
                .open(&path)?;
            file.write_all(content.as_bytes())?;
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
            Ok::<(), std::io::Error>(())
        }
        #[cfg(not(unix))]
        {
            std::fs::write(&path, content)?;
            Ok::<(), std::io::Error>(())
        }
    })
    .await
    .context("failed joining secure write task")??;
    Ok(())
}