diff --git a/src/cli/commands/auth_test.rs b/src/cli/commands/auth_test.rs index 751e357..c06a17f 100644 --- a/src/cli/commands/auth_test.rs +++ b/src/cli/commands/auth_test.rs @@ -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 { 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); diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index 0b507d7..eced93b 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -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 )), }, diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index 54cb82b..2604298 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -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, diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 81daf26..5185e6c 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -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, + token_arg: Option, +) -> Result { + 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 { + 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*****"); + } +} diff --git a/src/cli/commands/list_tests.rs b/src/cli/commands/list_tests.rs index 19775cd..00a3946 100644 --- a/src/cli/commands/list_tests.rs +++ b/src/cli/commands/list_tests.rs @@ -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(), diff --git a/src/core/config.rs b/src/core/config.rs index eee368f..72c7363 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -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, +} + +impl GitLabConfig { + /// Resolve token with priority: env var > config file. + pub fn resolve_token(&self) -> Result { + 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()); + } } diff --git a/src/core/paths.rs b/src/core/paths.rs index 7c25591..b59ee08 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -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::*;