use std::{ collections::BTreeMap, env, fs, io::Write, path::PathBuf, process::{self, Command}, time::{SystemTime, UNIX_EPOCH}, }; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use gotcha_gitea::{Client, Provider, RepositoryId, Url}; use serde::{Deserialize, Serialize}; type Result = std::result::Result; #[derive(Clone, Default, Deserialize, Serialize)] pub struct Server { pub url: String, pub token: String, #[serde(default)] pub provider: Provider, } #[derive(Default, Deserialize, Serialize)] pub struct Config { #[serde(skip)] path: PathBuf, #[serde(default)] pub servers: BTreeMap, } pub struct Selection { pub name: Option, pub url: String, pub token: Option, pub provider: Provider, pub repository: Option, } pub type RepositoryScope = RepositoryId; impl Config { pub fn load() -> Result { let home = env::var_os("HOME").ok_or("HOME is not set")?; Self::load_from(PathBuf::from(home).join(".config/gotcha/config")) } fn load_from(path: PathBuf) -> Result { if !path.exists() { return Ok(Self { path, ..Self::default() }); } let text = fs::read_to_string(&path) .map_err(|error| format!("cannot read {}: {error}", path.display()))?; let mut config: Self = serde_yaml::from_str(&text) .map_err(|error| format!("invalid {}: {error}", path.display()))?; config.path = path; for (name, server) in &config.servers { validate_name(name)?; Client::with_provider(&server.url, Some(&server.token), server.provider) .map_err(|error| format!("invalid server {name}: {error}"))?; } Ok(config) } pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> { let url = server_url(name)?; Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?; self.servers.insert( name.into(), Server { url, token: token.into(), provider, }, ); self.save() } pub fn logout(&mut self, name: &str) -> Result<()> { if self.servers.remove(name).is_none() { return Err(format!("server profile {name:?} does not exist")); } self.save() } pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result { if name.is_some() && url.is_some() { return Err("use either --server or --url, not both".into()); } let remotes = git_remotes().unwrap_or_default(); if let Some(name) = name { let server = self .servers .get(name) .ok_or_else(|| format!("server profile {name:?} does not exist"))?; return Ok(selection(Some(name), server, &remotes)); } if let Some(url) = url { let matches: Vec<_> = self .servers .iter() .filter(|(_, server)| same_instance(&server.url, url)) .collect(); return match matches.as_slice() { [] => Ok(selection( None, &Server { url: url.into(), token: String::new(), provider: Provider::Gitea, }, &remotes, )), [(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)), _ => Err("multiple profiles use that URL; select one with --server".into()), }; } let mut matches = BTreeMap::new(); for (name, server) in &self.servers { if let Some(scope) = remotes .iter() .find_map(|remote| repository_scope(&server.url, remote)) { matches.insert(name, (server, scope)); } } match matches.into_iter().collect::>().as_slice() { [] if self.servers.is_empty() => { Err("no servers configured; run `gotcha auth login SERVER`".into()) } [] => Err("no configured server matches this Git repository; use --server NAME".into()), [(name, (server, scope))] => Ok(Selection { name: Some((*name).clone()), url: server.url.clone(), token: Some(server.token.clone()), provider: server.provider, repository: Some(scope.clone()), }), _ => Err("multiple server profiles match this Git repository; use --server".into()), } } fn save(&self) -> Result<()> { let parent = self .path .parent() .ok_or_else(|| format!("invalid config path: {}", self.path.display()))?; fs::create_dir_all(parent) .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|error| error.to_string())? .as_nanos(); let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id())); let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?; let result = (|| -> std::io::Result<()> { let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); let mut file = options.open(&temporary)?; file.write_all(text.as_bytes())?; file.sync_all()?; fs::rename(&temporary, &self.path)?; #[cfg(unix)] fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?; Ok(()) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result.map_err(|error| format!("cannot write {}: {error}", self.path.display())) } } fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection { Selection { name: name.map(str::to_owned), url: server.url.clone(), token: (!server.token.is_empty()).then(|| server.token.clone()), provider: server.provider, repository: remotes .iter() .find_map(|remote| repository_scope(&server.url, remote)), } } fn validate_name(name: &str) -> Result<()> { server_url(name)?; Ok(()) } pub fn server_url(name: &str) -> Result { if name.is_empty() || name.contains('/') || name.contains('@') || name.chars().any(char::is_whitespace) { return Err("server name must be a hostname, optionally followed by a port".into()); } let url = format!("https://{name}"); let parsed = Url::parse(&url).map_err(|_| "invalid server name")?; if parsed.host_str().is_none() || parsed.path() != "/" { return Err("server name must be a hostname, optionally followed by a port".into()); } if parsed.query().is_some() || parsed.fragment().is_some() { return Err("server name must be a hostname, optionally followed by a port".into()); } Ok(url) } fn same_instance(left: &str, right: &str) -> bool { let api_url = |url| { Client::new(url, None) .ok() .map(|client| client.api_url().clone()) }; api_url(left) == api_url(right) } fn git_remotes() -> Result> { let output = Command::new("git") .args(["config", "--get-regexp", r"^remote\..*\.url$"]) .output() .map_err(|error| format!("cannot inspect Git remotes: {error}"))?; if !output.status.success() { return Ok(Vec::new()); } Ok(String::from_utf8_lossy(&output.stdout) .lines() .filter_map(|line| line.split_once(char::is_whitespace)) .map(|(_, url)| url.trim().to_owned()) .collect()) } fn repository_scope(server_url: &str, remote: &str) -> Option { let server = Url::parse(server_url).ok()?; let server_host = server.host_str()?; let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) { ( url.host_str()?.to_owned(), url.path().trim_matches('/').to_owned(), matches!(url.scheme(), "http" | "https"), ) } else { let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest); let (host, path) = remote.split_once(':')?; (host.into(), path.trim_matches('/').into(), false) }; if !server_host.eq_ignore_ascii_case(&remote_host) { return None; } let prefix = server.path().trim_matches('/'); if is_http && !prefix.is_empty() { remote_path = remote_path .strip_prefix(prefix)? .strip_prefix('/')? .to_owned(); } let parts: Vec<_> = remote_path .split('/') .filter(|part| !part.is_empty()) .collect(); let [.., owner, repository] = parts.as_slice() else { return None; }; let repository = repository.strip_suffix(".git").unwrap_or(repository); (!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope { owner: (*owner).into(), repository: repository.into(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn config_round_trip_and_remote_scope() { let directory = env::temp_dir().join(format!( "gotcha-config-test-{}-{}", process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let path = directory.join("config"); let mut config = Config { path: path.clone(), ..Config::default() }; assert_eq!( config.select(None, None).err().unwrap(), "no servers configured; run `gotcha auth login SERVER`" ); config .login("code.example", "secret", Provider::Forgejo) .unwrap(); let loaded = Config::load_from(path).unwrap(); assert_eq!(loaded.servers["code.example"].token, "secret"); assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo); assert_eq!( loaded.select(Some("code.example"), None).unwrap().provider, Provider::Forgejo ); assert!( fs::read_to_string(&loaded.path) .unwrap() .contains("provider: forgejo") ); let legacy: Server = serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap(); assert_eq!(legacy.provider, Provider::Gitea); let scope = repository_scope( "https://code.example/gitea", "https://code.example/gitea/alice/project.git", ) .unwrap(); assert_eq!( (scope.owner.as_str(), scope.repository.as_str()), ("alice", "project") ); #[cfg(unix)] assert_eq!( fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777, 0o600 ); fs::remove_dir_all(directory).unwrap(); } }