6.0 KiB · text History 1294137
//! Where the (forge URL, API token) pair lives between invocations.
//!
//! One file, `$XDG_CONFIG_HOME/gitgud/config.toml`, written 0600. This
//! mirrors the Android client's `TokenStore`: the pair is the only
//! authentication state we hold, and a token is a bearer credential, so
//! the file is treated like an SSH key rather than a preference.
//!
//! Environment variables win over the file so a CI job or a one-off
//! against a second forge never has to touch it.

use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

pub const ENV_URL: &str = "GITGUD_URL";
pub const ENV_TOKEN: &str = "GITGUD_TOKEN";
pub const ENV_REPO: &str = "GITGUD_REPO";

#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Base URL of the forge, no trailing slash.
    pub url: Option<String>,
    pub token: Option<String>,
    /// Used when neither `--repo` nor a git remote names one.
    pub default_repo: Option<String>,
}

impl Config {
    pub fn path() -> Result<PathBuf> {
        let dir = dirs::config_dir().ok_or_else(|| {
            anyhow!("no config directory on this platform; set {ENV_URL} and {ENV_TOKEN} instead")
        })?;
        Ok(dir.join("gitgud").join("config.toml"))
    }

    pub fn load() -> Result<Self> {
        let path = Self::path()?;
        match std::fs::read_to_string(&path) {
            Ok(raw) => toml::from_str(&raw)
                .with_context(|| format!("{} is not valid TOML", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
        }
    }

    pub fn save(&self) -> Result<PathBuf> {
        let path = Self::path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        let body = toml::to_string_pretty(self).context("serializing config")?;
        std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
        restrict(&path)?;
        Ok(path)
    }

    /// Forget the credentials but keep any non-secret preferences.
    pub fn clear_credentials(&mut self) {
        self.url = None;
        self.token = None;
    }
}

#[cfg(unix)]
fn restrict(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
        .with_context(|| format!("tightening permissions on {}", path.display()))
}

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

/// The credentials a command actually runs with: flag, then environment,
/// then the config file.
#[derive(Debug, Clone)]
pub struct Credentials {
    pub url: String,
    pub token: String,
}

pub fn resolve(
    flag_url: Option<&str>,
    flag_token: Option<&str>,
    cfg: &Config,
) -> Result<Credentials> {
    let url = flag_url
        .map(str::to_owned)
        .or_else(|| std::env::var(ENV_URL).ok())
        .or_else(|| cfg.url.clone())
        .ok_or_else(|| anyhow!("not signed in — run `gg login` (or set {ENV_URL}/{ENV_TOKEN})"))?;
    let token = flag_token
        .map(str::to_owned)
        .or_else(|| std::env::var(ENV_TOKEN).ok())
        .or_else(|| cfg.token.clone())
        .ok_or_else(|| anyhow!("no API token — run `gg login` (or set {ENV_TOKEN})"))?;
    Ok(Credentials {
        url: normalize_url(&url),
        token,
    })
}

/// Trim the trailing slash and add a scheme if the user typed a bare host.
pub fn normalize_url(raw: &str) -> String {
    let trimmed = raw.trim().trim_end_matches('/');
    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
        trimmed.to_string()
    } else {
        format!("https://{trimmed}")
    }
}

/// Upgrade `http://host` to `https://host` for FQDN hosts, leaving IPs,
/// `localhost` and bare hostnames alone — those are dev / LAN setups
/// where plaintext is the only thing that works.
///
/// Reason: the pairing URI encodes whatever `GitGudWeb.Endpoint.url()`
/// reports, and behind a TLS-terminating proxy that can come out as
/// `http://forge.example.com` if the proxy drops `X-Forwarded-Proto`.
/// Coercing here means such a forge is reached over TLS rather than
/// having the bearer token sent in the clear.
pub fn coerce_https(url: &str) -> String {
    let Some(rest) = url.strip_prefix("http://") else {
        return url.to_string();
    };
    let host = match url::Url::parse(url) {
        Ok(parsed) => parsed.host_str().unwrap_or_default().to_string(),
        Err(_) => return url.to_string(),
    };
    if looks_local(&host) {
        url.to_string()
    } else {
        format!("https://{rest}")
    }
}

fn looks_local(host: &str) -> bool {
    let h = host.to_lowercase();
    if h == "localhost" {
        return true;
    }
    // Unqualified hostname — a bare devbox name.
    if !h.contains('.') {
        return true;
    }
    // IPv6 literal, which `Url` hands back inside brackets.
    if h.contains(':') || h.starts_with('[') {
        return true;
    }
    // IPv4 dotted-quad.
    let octets: Vec<&str> = h.split('.').collect();
    octets.len() == 4 && octets.iter().all(|o| o.parse::<u8>().is_ok())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn upgrades_plaintext_fqdns() {
        assert_eq!(
            coerce_https("http://forge.example.com"),
            "https://forge.example.com"
        );
    }

    #[test]
    fn leaves_local_targets_alone() {
        for local in [
            "http://localhost:4000",
            "http://10.0.2.2:4000",
            "http://devbox:4000",
        ] {
            assert_eq!(coerce_https(local), local);
        }
    }

    #[test]
    fn normalizes_bare_hosts_and_slashes() {
        assert_eq!(
            normalize_url("forge.example.com/"),
            "https://forge.example.com"
        );
        assert_eq!(
            normalize_url("http://localhost:4000/"),
            "http://localhost:4000"
        );
    }
}