use crate::cloud::client::CloudClient;
use crate::error::{CoreError, InternalResultExt as _};
use crate::infra::crypto::{decrypt_secret, encrypt_secret};
pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
const PAT_KEY_PREFIX: &str = "gitlab_pat::";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitlabTokenSource {
EnvDifflore,
EnvGitlab,
Storage,
}
impl GitlabTokenSource {
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::EnvDifflore => "DIFFLORE_GITLAB_TOKEN env var",
Self::EnvGitlab => "GITLAB_TOKEN env var",
Self::Storage => "encrypted local storage",
}
}
}
pub fn normalize_gitlab_host(input: &str) -> crate::Result<String> {
let trimmed = input.trim();
let without_scheme = trimmed
.strip_prefix("https://")
.or_else(|| trimmed.strip_prefix("http://"))
.unwrap_or(trimmed);
let without_slash = without_scheme.trim_end_matches('/');
if without_slash.is_empty() {
return Err(CoreError::Validation(
"GitLab host is empty; pass --host like gitlab.example.com".to_owned(),
));
}
if without_slash.contains('/') || without_slash.contains('@') {
return Err(CoreError::Validation(format!(
"invalid GitLab host {input:?}: pass a bare host like gitlab.example.com (no path or credentials)"
)));
}
let (name, port) = without_slash
.split_once(':')
.map_or((without_slash, None), |(name, port)| (name, Some(port)));
let name = name.trim_end_matches('.');
let host_chars_ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-');
let port_ok = port.is_none_or(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
if !host_chars_ok || !port_ok {
return Err(CoreError::Validation(format!(
"invalid GitLab host {input:?}: pass a bare host like gitlab.example.com (optionally :port)"
)));
}
Ok(match port {
Some(port) => format!("{name}:{port}"),
None => name.to_owned(),
}
.to_ascii_lowercase())
}
#[must_use]
pub fn pat_storage_key(host: &str) -> String {
format!("{PAT_KEY_PREFIX}{host}")
}
pub async fn save_pat(host: &str, token: &str) -> crate::Result<()> {
let trimmed = token.trim();
if trimmed.is_empty() {
return Err(CoreError::Validation("GitLab token is empty.".to_owned()));
}
let encrypted = encrypt_secret(trimmed)?;
let pool = CloudClient::auth_pool_public().await.internal()?;
let key = pat_storage_key(host);
sqlx::query("INSERT OR REPLACE INTO auth (key, value) VALUES (?1, ?2)")
.bind(&key)
.bind(encrypted)
.execute(&pool)
.await
.map_err(|e| CoreError::Internal(format!("could not save GitLab token: {e}")))?;
Ok(())
}
pub async fn load_stored_pat(host: &str) -> Option<String> {
let pool = match CloudClient::auth_pool_public().await {
Ok(pool) => pool,
Err(e) => {
eprintln!("Could not open auth storage to load GitLab token for {host}: {e}");
return None;
}
};
let raw: String = match sqlx::query_scalar("SELECT value FROM auth WHERE key = ?1")
.bind(pat_storage_key(host))
.fetch_optional(&pool)
.await
{
Ok(Some(raw)) => raw,
Ok(None) => return None,
Err(e) => {
eprintln!("Could not load stored GitLab token for {host}: {e}");
return None;
}
};
match decrypt_secret(&raw) {
Ok(token) => Some(token),
Err(e) => {
eprintln!(
"Stored GitLab token for {host} could not be decrypted: {e}. \
Run `difflore auth gitlab --host {host}` again to replace it."
);
None
}
}
}
pub async fn remove_pat(host: &str) -> crate::Result<bool> {
let pool = CloudClient::auth_pool_public().await.internal()?;
let result = sqlx::query("DELETE FROM auth WHERE key = ?1")
.bind(pat_storage_key(host))
.execute(&pool)
.await
.map_err(|e| CoreError::Internal(format!("could not remove GitLab token: {e}")))?;
Ok(result.rows_affected() > 0)
}
pub async fn resolve_token(host: &str) -> Option<(String, GitlabTokenSource)> {
if let Some(token) = crate::infra::env::non_empty(crate::infra::env::DIFFLORE_GITLAB_TOKEN) {
return Some((token, GitlabTokenSource::EnvDifflore));
}
if let Some(token) = crate::infra::env::non_empty(crate::infra::env::GITLAB_TOKEN) {
return Some((token, GitlabTokenSource::EnvGitlab));
}
load_stored_pat(host)
.await
.map(|token| (token, GitlabTokenSource::Storage))
}
pub async fn configured_hosts() -> Vec<String> {
let pool = match CloudClient::auth_pool_public().await {
Ok(pool) => pool,
Err(e) => {
eprintln!("Could not open auth storage to list configured GitLab hosts: {e}");
return Vec::new();
}
};
let keys: Vec<String> = match sqlx::query_scalar(
"SELECT key FROM auth WHERE key LIKE 'gitlab_pat::%' ORDER BY key",
)
.fetch_all(&pool)
.await
{
Ok(keys) => keys,
Err(e) => {
eprintln!("Could not list configured GitLab hosts: {e}");
return Vec::new();
}
};
keys.iter()
.filter_map(|key| key.strip_prefix(PAT_KEY_PREFIX))
.filter(|host| !host.is_empty())
.filter_map(|host| normalize_gitlab_host(host).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pat_storage_key_is_host_scoped() {
assert_eq!(pat_storage_key("gitlab.com"), "gitlab_pat::gitlab.com");
assert_eq!(
pat_storage_key("gitlab.corp.example:8443"),
"gitlab_pat::gitlab.corp.example:8443"
);
}
#[test]
fn normalize_gitlab_host_accepts_bare_hosts_and_pasted_origins() {
let cases: &[(&str, &str)] = &[
("gitlab.com", "gitlab.com"),
("GitLab.COM", "gitlab.com"),
(" gitlab.corp.example ", "gitlab.corp.example"),
("gitlab.com.", "gitlab.com"),
("https://gitlab.corp.example", "gitlab.corp.example"),
("https://gitlab.corp.example/", "gitlab.corp.example"),
(
"https://GitLab.Corp.Example.:8443/",
"gitlab.corp.example:8443",
),
(
"http://gitlab.corp.example:8443",
"gitlab.corp.example:8443",
),
];
for (input, expected) in cases {
assert_eq!(
normalize_gitlab_host(input).unwrap(),
*expected,
"input: {input}"
);
}
}
#[test]
fn normalize_gitlab_host_rejects_paths_credentials_and_junk() {
for bad in [
"",
" ",
"https://gitlab.com/group/project",
"git@gitlab.com",
"gitlab.com:port",
"gitlab com",
"https://",
] {
assert!(
normalize_gitlab_host(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn token_source_descriptions_name_the_actual_origin() {
assert!(
GitlabTokenSource::EnvDifflore
.describe()
.contains("DIFFLORE_GITLAB_TOKEN")
);
assert!(
GitlabTokenSource::EnvGitlab
.describe()
.contains("GITLAB_TOKEN")
);
assert!(GitlabTokenSource::Storage.describe().contains("storage"));
}
}