feat(token): add stored token support with resolve_token and token_source
Introduce a centralized token resolution system that supports both
environment variables and config-file-stored tokens with clear priority
(env var wins). This enables cron-based sync which runs in minimal
shell environments without env vars.
Core changes:
- GitLabConfig gains optional `token` field and `resolve_token()` method
that checks env var first, then config file, returning trimmed values
- `token_source()` returns human-readable provenance ("environment variable"
or "config file") for diagnostics
- `ensure_config_permissions()` enforces 0600 on config files containing
tokens (Unix only, no-op on other platforms)
New CLI commands:
- `lore token set [--token VALUE]` — validates against GitLab API, stores
in config, enforces file permissions. Supports flag, stdin pipe, or
interactive entry.
- `lore token show [--unmask]` — displays masked token with source label
Consumers updated to use resolve_token():
- auth_test: removes manual env var lookup
- doctor: shows token source in health check output
- ingest: uses centralized resolution
Includes 10 unit tests for resolve/source logic and 2 for mask_token.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::core::config::Config;
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::error::Result;
|
||||
use crate::gitlab::GitLabClient;
|
||||
|
||||
pub struct AuthTestResult {
|
||||
@@ -11,17 +11,7 @@ pub struct AuthTestResult {
|
||||
pub async fn run_auth_test(config_path: Option<&str>) -> Result<AuthTestResult> {
|
||||
let config = Config::load(config_path)?;
|
||||
|
||||
let token = std::env::var(&config.gitlab.token_env_var)
|
||||
.map(|t| t.trim().to_string())
|
||||
.map_err(|_| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
});
|
||||
}
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
|
||||
|
||||
@@ -240,14 +240,14 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
};
|
||||
};
|
||||
|
||||
let token = match std::env::var(&config.gitlab.token_env_var) {
|
||||
Ok(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||||
_ => {
|
||||
let token = match config.gitlab.resolve_token() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return GitLabCheck {
|
||||
result: CheckResult {
|
||||
status: CheckStatus::Error,
|
||||
message: Some(format!(
|
||||
"{} not set in environment",
|
||||
"Token not set. Run 'lore token set' or export {}.",
|
||||
config.gitlab.token_env_var
|
||||
)),
|
||||
},
|
||||
@@ -257,6 +257,8 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
}
|
||||
};
|
||||
|
||||
let source = config.gitlab.token_source().unwrap_or("unknown");
|
||||
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
|
||||
match client.get_current_user().await {
|
||||
@@ -264,7 +266,7 @@ async fn check_gitlab(config: Option<&Config>) -> GitLabCheck {
|
||||
result: CheckResult {
|
||||
status: CheckStatus::Ok,
|
||||
message: Some(format!(
|
||||
"{} (authenticated as @{})",
|
||||
"{} (authenticated as @{}, token from {source})",
|
||||
config.gitlab.base_url, user.username
|
||||
)),
|
||||
},
|
||||
|
||||
@@ -293,10 +293,7 @@ async fn run_ingest_inner(
|
||||
);
|
||||
lock.acquire(force)?;
|
||||
|
||||
let token =
|
||||
std::env::var(&config.gitlab.token_env_var).map_err(|_| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let client = GitLabClient::new(
|
||||
&config.gitlab.base_url,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::fs;
|
||||
use std::io::{IsTerminal, Read};
|
||||
|
||||
use crate::core::config::{MinimalConfig, MinimalGitLabConfig, ProjectConfig};
|
||||
use crate::core::config::{Config, MinimalConfig, MinimalGitLabConfig, ProjectConfig};
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::core::paths::{get_config_path, get_data_dir};
|
||||
use crate::core::paths::{ensure_config_permissions, get_config_path, get_data_dir};
|
||||
use crate::gitlab::{GitLabClient, GitLabProject};
|
||||
|
||||
pub struct InitInputs {
|
||||
@@ -172,3 +173,141 @@ pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result<InitRe
|
||||
default_project: inputs.default_project,
|
||||
})
|
||||
}
|
||||
|
||||
// ── token set / show ──
|
||||
|
||||
pub struct TokenSetResult {
|
||||
pub username: String,
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
pub struct TokenShowResult {
|
||||
pub token: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Read token from --token flag or stdin, validate against GitLab, store in config.
|
||||
pub async fn run_token_set(
|
||||
config_path_override: Option<&str>,
|
||||
token_arg: Option<String>,
|
||||
) -> Result<TokenSetResult> {
|
||||
let config_path = get_config_path(config_path_override);
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(LoreError::ConfigNotFound {
|
||||
path: config_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve token value: flag > stdin > error
|
||||
let token = if let Some(t) = token_arg {
|
||||
t.trim().to_string()
|
||||
} else if !std::io::stdin().is_terminal() {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to read token from stdin: {e}")))?;
|
||||
buf.trim().to_string()
|
||||
} else {
|
||||
return Err(LoreError::Other(
|
||||
"No token provided. Use --token or pipe to stdin.".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(LoreError::Other("Token cannot be empty.".to_string()));
|
||||
}
|
||||
|
||||
// Load config to get the base URL for validation
|
||||
let config = Config::load(config_path_override)?;
|
||||
|
||||
// Validate token against GitLab
|
||||
let client = GitLabClient::new(&config.gitlab.base_url, &token, None);
|
||||
let user = client.get_current_user().await.map_err(|e| {
|
||||
if matches!(e, LoreError::GitLabAuthFailed) {
|
||||
LoreError::Other("Token validation failed: authentication rejected by GitLab.".into())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
// Read config as raw JSON, insert token, write back
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to read config file: {e}")))?;
|
||||
|
||||
let mut json: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid {
|
||||
details: format!("Invalid JSON in config file: {e}"),
|
||||
})?;
|
||||
|
||||
json["gitlab"]["token"] = serde_json::Value::String(token);
|
||||
|
||||
let output = serde_json::to_string_pretty(&json)
|
||||
.map_err(|e| LoreError::Other(format!("Failed to serialize config: {e}")))?;
|
||||
fs::write(&config_path, format!("{output}\n"))?;
|
||||
|
||||
// Enforce permissions
|
||||
ensure_config_permissions(&config_path);
|
||||
|
||||
Ok(TokenSetResult {
|
||||
username: user.username,
|
||||
config_path: config_path.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Show the current token (masked or unmasked) and its source.
|
||||
pub fn run_token_show(config_path_override: Option<&str>, unmask: bool) -> Result<TokenShowResult> {
|
||||
let config = Config::load(config_path_override)?;
|
||||
|
||||
let source = config
|
||||
.gitlab
|
||||
.token_source()
|
||||
.ok_or_else(|| LoreError::TokenNotSet {
|
||||
env_var: config.gitlab.token_env_var.clone(),
|
||||
})?;
|
||||
|
||||
let token = config.gitlab.resolve_token()?;
|
||||
|
||||
let display_token = if unmask { token } else { mask_token(&token) };
|
||||
|
||||
Ok(TokenShowResult {
|
||||
token: display_token,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_token(token: &str) -> String {
|
||||
let len = token.len();
|
||||
if len <= 8 {
|
||||
"*".repeat(len)
|
||||
} else {
|
||||
let visible = &token[..4];
|
||||
format!("{visible}{}", "*".repeat(len - 4))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_token_hides_short_tokens_completely() {
|
||||
assert_eq!(mask_token(""), "");
|
||||
assert_eq!(mask_token("a"), "*");
|
||||
assert_eq!(mask_token("abcd"), "****");
|
||||
assert_eq!(mask_token("abcdefgh"), "********");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_token_reveals_first_four_chars_for_long_tokens() {
|
||||
assert_eq!(mask_token("abcdefghi"), "abcd*****");
|
||||
assert_eq!(mask_token("glpat-xyzABC123456"), "glpa**************");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_token_boundary_at_nine_chars() {
|
||||
// 8 chars → fully masked, 9 chars → first 4 visible
|
||||
assert_eq!(mask_token("12345678"), "********");
|
||||
assert_eq!(mask_token("123456789"), "1234*****");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn test_config(default_project: Option<&str>) -> Config {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
|
||||
@@ -12,6 +12,44 @@ pub struct GitLabConfig {
|
||||
|
||||
#[serde(rename = "tokenEnvVar", default = "default_token_env_var")]
|
||||
pub token_env_var: String,
|
||||
|
||||
/// Optional stored token (env var takes priority when both are set).
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl GitLabConfig {
|
||||
/// Resolve token with priority: env var > config file.
|
||||
pub fn resolve_token(&self) -> Result<String> {
|
||||
if let Ok(val) = std::env::var(&self.token_env_var)
|
||||
&& !val.trim().is_empty()
|
||||
{
|
||||
return Ok(val.trim().to_string());
|
||||
}
|
||||
if let Some(ref t) = self.token
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Ok(t.trim().to_string());
|
||||
}
|
||||
Err(LoreError::TokenNotSet {
|
||||
env_var: self.token_env_var.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for where the token was found, or `None`.
|
||||
pub fn token_source(&self) -> Option<&'static str> {
|
||||
if let Ok(val) = std::env::var(&self.token_env_var)
|
||||
&& !val.trim().is_empty()
|
||||
{
|
||||
return Some("environment variable");
|
||||
}
|
||||
if let Some(ref t) = self.token
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Some("config file");
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn default_token_env_var() -> String {
|
||||
@@ -531,6 +569,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -554,6 +593,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -574,6 +614,7 @@ mod tests {
|
||||
gitlab: GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||
token: None,
|
||||
},
|
||||
projects: vec![ProjectConfig {
|
||||
path: "group/project".to_string(),
|
||||
@@ -786,4 +827,84 @@ mod tests {
|
||||
};
|
||||
validate_scoring(&scoring).unwrap();
|
||||
}
|
||||
|
||||
// ── token_source / resolve_token ────────────────────────────────
|
||||
|
||||
/// Build a `GitLabConfig` that reads from the given unique env var name
|
||||
/// so parallel tests never collide.
|
||||
fn gitlab_cfg_with_env(env_var: &str, token: Option<&str>) -> GitLabConfig {
|
||||
GitLabConfig {
|
||||
base_url: "https://gitlab.example.com".to_string(),
|
||||
token_env_var: env_var.to_string(),
|
||||
token: token.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_env_wins_over_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_ENV_WINS";
|
||||
// SAFETY: unique var name, no other code reads it.
|
||||
unsafe { std::env::set_var(VAR, "env-tok") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.token_source(), Some("environment variable"));
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_falls_back_to_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_FALLBACK";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.token_source(), Some("config file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_none_when_both_absent() {
|
||||
const VAR: &str = "LORE_TEST_TS_NONE";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, None);
|
||||
assert_eq!(cfg.token_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_ignores_whitespace_only_env() {
|
||||
const VAR: &str = "LORE_TEST_TS_WS_ENV";
|
||||
unsafe { std::env::set_var(VAR, " ") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("real"));
|
||||
assert_eq!(cfg.token_source(), Some("config file"));
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_source_ignores_whitespace_only_config() {
|
||||
const VAR: &str = "LORE_TEST_TS_WS_CFG";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some(" \t "));
|
||||
assert_eq!(cfg.token_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_env_wins_over_config() {
|
||||
const VAR: &str = "LORE_TEST_RT_ENV_WINS";
|
||||
unsafe { std::env::set_var(VAR, " env-tok ") };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
|
||||
assert_eq!(cfg.resolve_token().unwrap(), "env-tok");
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_config_fallback() {
|
||||
const VAR: &str = "LORE_TEST_RT_FALLBACK";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, Some(" config-tok "));
|
||||
assert_eq!(cfg.resolve_token().unwrap(), "config-tok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_token_err_when_both_absent() {
|
||||
const VAR: &str = "LORE_TEST_RT_NONE";
|
||||
unsafe { std::env::remove_var(VAR) };
|
||||
let cfg = gitlab_cfg_with_env(VAR, None);
|
||||
assert!(cfg.resolve_token().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,36 @@ fn get_xdg_data_dir() -> PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Enforce restrictive permissions (0600) on the config file.
|
||||
/// Warns to stderr if permissions were too open, then tightens them.
|
||||
#[cfg(unix)]
|
||||
pub fn ensure_config_permissions(path: &std::path::Path) {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
let mode = meta.mode() & 0o777;
|
||||
if mode != 0o600 {
|
||||
eprintln!(
|
||||
"Warning: config file permissions were {mode:04o}, tightening to 0600: {}",
|
||||
path.display()
|
||||
);
|
||||
let _ = set_permissions_600(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_permissions_600(path: &std::path::Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)
|
||||
}
|
||||
|
||||
/// No-op on non-Unix platforms.
|
||||
#[cfg(not(unix))]
|
||||
pub fn ensure_config_permissions(_path: &std::path::Path) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user