use anyhow::{anyhow, Context, Result};
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
pub const ENV_CONFIG: &str = "DSC_CONFIG";
pub const ENV_CONFIG_HOME: &str = "DSC_CONFIG_HOME";
fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
}
fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<u64>::deserialize(deserializer)?;
Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub discourse: Vec<DiscourseConfig>,
#[serde(default)]
pub harden: HardenConfig,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct HardenConfig {
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub new_user: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
pub ssh_port: Option<u64>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub docker_install_url: Option<String>,
#[serde(default)]
pub docker_rootless: Option<bool>,
#[serde(default)]
pub swap_size_gb: Option<u32>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub journald_max_use: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub timezone: Option<String>,
#[serde(default)]
pub unattended_security_upgrades: Option<bool>,
#[serde(default)]
pub fail2ban: Option<bool>,
#[serde(default)]
pub mosh: Option<bool>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub sshd_ciphers: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub sshd_kex: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub sshd_macs: Option<String>,
#[serde(default)]
pub extra_ufw_allow: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DiscourseConfig {
pub name: String,
pub baseurl: String,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub fullname: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub apikey: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub api_username: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
pub changelog_topic_id: Option<u64>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub ssh_host: Option<String>,
#[serde(default)]
pub docker_rootless: Option<bool>,
}
pub fn load_config(path: &Path) -> Result<Config> {
if !path.exists() {
return Ok(Config::default());
}
let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
warn_on_discourse_names(&config);
Ok(config)
}
pub fn save_config(path: &Path, config: &Config) -> Result<()> {
let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
write_config_file(path, raw.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("writing {}", path.display()))?;
file.write_all(raw)
.with_context(|| format!("writing {}", path.display()))?;
let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
eprintln!(
"Warning: unable to tighten permissions on {}: {}",
path.display(),
err
);
}
}
Ok(())
}
#[cfg(not(unix))]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
config.discourse.iter().find(|d| d.name == name)
}
pub fn find_discourse_mut<'a>(
config: &'a mut Config,
name: &str,
) -> Option<&'a mut DiscourseConfig> {
config.discourse.iter_mut().find(|d| d.name == name)
}
fn warn_on_discourse_names(config: &Config) {
for discourse in &config.discourse {
if discourse.name.chars().any(|ch| ch.is_whitespace()) {
eprintln!(
"Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
discourse.name
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
Flag(PathBuf),
EnvVar(PathBuf),
Discovered(PathBuf),
Default(PathBuf),
}
impl ConfigSource {
pub fn path(&self) -> &Path {
match self {
Self::Flag(p) | Self::EnvVar(p) | Self::Discovered(p) | Self::Default(p) => p,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Flag(_) => "via --config flag",
Self::EnvVar(_) => "via $DSC_CONFIG",
Self::Discovered(_) => "from search hierarchy",
Self::Default(_) => "default (no config found)",
}
}
}
pub fn resolve_config_source(flag: Option<PathBuf>) -> Result<ConfigSource> {
resolve_config_source_with_env(flag, |k| std::env::var_os(k))
}
fn resolve_config_source_with_env<F>(flag: Option<PathBuf>, env: F) -> Result<ConfigSource>
where
F: Fn(&str) -> Option<OsString> + Copy,
{
if let Some(path) = flag {
if !path.exists() {
return Err(anyhow!(
"config file not found: {} (specified via --config)",
path.display()
));
}
return Ok(ConfigSource::Flag(path));
}
if let Some(raw) = env(ENV_CONFIG) {
let path = PathBuf::from(raw);
if !path.exists() {
return Err(anyhow!(
"config file not found: {} (specified via ${})",
path.display(),
ENV_CONFIG
));
}
return Ok(ConfigSource::EnvVar(path));
}
let candidates = config_search_paths_with_env(env);
if let Some(found) = candidates.into_iter().find(|c| c.exists()) {
return Ok(ConfigSource::Discovered(found));
}
Ok(ConfigSource::Default(PathBuf::from("dsc.toml")))
}
pub fn config_search_paths() -> Vec<PathBuf> {
config_search_paths_with_env(|k| std::env::var_os(k))
}
fn config_search_paths_with_env<F>(env: F) -> Vec<PathBuf>
where
F: Fn(&str) -> Option<OsString>,
{
let mut candidates = vec![PathBuf::from("dsc.toml")];
let config_home: Option<PathBuf> = env(ENV_CONFIG_HOME)
.map(PathBuf::from)
.or_else(|| env("XDG_CONFIG_HOME").map(|x| PathBuf::from(x).join("dsc")))
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config").join("dsc")));
if let Some(dir) = config_home {
candidates.push(dir.join("dsc.toml"));
}
#[cfg(unix)]
{
if let Some(xdg_config_dirs) = env("XDG_CONFIG_DIRS") {
for dir in std::env::split_paths(&xdg_config_dirs) {
candidates.push(dir.join("dsc").join("dsc.toml"));
}
} else {
candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
}
candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
candidates.push(PathBuf::from("/etc/dsc.toml"));
candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
fn env_from<'a>(
pairs: &'a HashMap<&'static str, OsString>,
) -> impl Fn(&str) -> Option<OsString> + Copy + 'a {
move |k: &str| pairs.get(k).cloned()
}
fn osstr<S: AsRef<OsStr>>(s: S) -> OsString {
s.as_ref().to_os_string()
}
#[test]
fn flag_wins_over_env_and_discovery() {
let dir = tempfile::tempdir().expect("tempdir");
let flag_file = dir.path().join("flag.toml");
let env_file = dir.path().join("env.toml");
std::fs::write(&flag_file, "").unwrap();
std::fs::write(&env_file, "").unwrap();
let mut env = HashMap::new();
env.insert(ENV_CONFIG, osstr(&env_file));
let source =
resolve_config_source_with_env(Some(flag_file.clone()), env_from(&env)).unwrap();
assert!(matches!(source, ConfigSource::Flag(_)));
assert_eq!(source.path(), flag_file);
}
#[test]
fn missing_flag_path_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope.toml");
let env: HashMap<&'static str, OsString> = HashMap::new();
let err = resolve_config_source_with_env(Some(missing), env_from(&env)).unwrap_err();
assert!(err.to_string().contains("--config"));
}
#[test]
fn dsc_config_env_wins_over_discovery() {
let dir = tempfile::tempdir().expect("tempdir");
let env_file = dir.path().join("env.toml");
std::fs::write(&env_file, "").unwrap();
let home_dir = dir.path().join("home");
let dsc_dir = home_dir.join(".config").join("dsc");
std::fs::create_dir_all(&dsc_dir).unwrap();
std::fs::write(dsc_dir.join("dsc.toml"), "").unwrap();
let mut env = HashMap::new();
env.insert(ENV_CONFIG, osstr(&env_file));
env.insert("HOME", osstr(&home_dir));
let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
assert!(matches!(source, ConfigSource::EnvVar(_)));
assert_eq!(source.path(), env_file);
}
#[test]
fn missing_dsc_config_env_path_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("missing.toml");
let mut env = HashMap::new();
env.insert(ENV_CONFIG, osstr(&missing));
let err = resolve_config_source_with_env(None, env_from(&env)).unwrap_err();
assert!(err.to_string().contains("$DSC_CONFIG"));
}
#[test]
fn dsc_config_home_redirects_step_4() {
let dir = tempfile::tempdir().expect("tempdir");
let custom_home = dir.path().join("custom");
std::fs::create_dir_all(&custom_home).unwrap();
std::fs::write(custom_home.join("dsc.toml"), "").unwrap();
let mut env = HashMap::new();
env.insert(ENV_CONFIG_HOME, osstr(&custom_home));
let candidates = config_search_paths_with_env(env_from(&env));
assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
assert_eq!(candidates[1], custom_home.join("dsc.toml"));
}
#[test]
fn unset_config_home_reproduces_home_config_dsc() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path().to_path_buf();
let mut env = HashMap::new();
env.insert("HOME", osstr(&home));
let candidates = config_search_paths_with_env(env_from(&env));
assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
assert_eq!(
candidates[1],
home.join(".config").join("dsc").join("dsc.toml")
);
}
#[test]
fn xdg_config_home_default_used_when_dsc_config_home_unset() {
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("xdg");
let mut env = HashMap::new();
env.insert("XDG_CONFIG_HOME", osstr(&xdg));
let candidates = config_search_paths_with_env(env_from(&env));
assert_eq!(candidates[1], xdg.join("dsc").join("dsc.toml"));
}
#[test]
fn dsc_config_home_overrides_xdg_config_home() {
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("xdg");
let dsc_home = dir.path().join("custom_dsc_home");
let mut env = HashMap::new();
env.insert("XDG_CONFIG_HOME", osstr(&xdg));
env.insert(ENV_CONFIG_HOME, osstr(&dsc_home));
let candidates = config_search_paths_with_env(env_from(&env));
assert_eq!(candidates[1], dsc_home.join("dsc.toml"));
}
#[test]
fn unset_everything_resolution_matches_legacy_order() {
let env: HashMap<&'static str, OsString> = HashMap::new();
let candidates = config_search_paths_with_env(env_from(&env));
assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
#[cfg(unix)]
{
assert!(candidates.contains(&PathBuf::from("/etc/xdg/dsc/dsc.toml")));
assert!(candidates.contains(&PathBuf::from("/etc/dsc/dsc.toml")));
assert!(candidates.contains(&PathBuf::from("/etc/dsc.toml")));
assert!(candidates.contains(&PathBuf::from("/usr/local/etc/dsc.toml")));
}
}
#[test]
fn no_config_anywhere_returns_default() {
let dir = tempfile::tempdir().expect("tempdir");
let mut env = HashMap::new();
env.insert("HOME", osstr(dir.path()));
if !PathBuf::from("dsc.toml").exists() {
let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
assert!(matches!(source, ConfigSource::Default(_)));
}
}
}