//! 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"
);
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.0 KiB · text
History
1294137