1use anyhow::{anyhow, Context, Result};
2use serde::de::Deserializer;
3use serde::{Deserialize, Serialize};
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8pub const ENV_CONFIG: &str = "DSC_CONFIG";
11
12pub const ENV_CONFIG_HOME: &str = "DSC_CONFIG_HOME";
16
17fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
18where
19 D: Deserializer<'de>,
20{
21 let value = Option::<String>::deserialize(deserializer)?;
22 Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
23}
24
25fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
26where
27 D: Deserializer<'de>,
28{
29 let value = Option::<u64>::deserialize(deserializer)?;
30 Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
31}
32
33#[derive(Debug, Serialize, Deserialize, Default, Clone)]
35pub struct Config {
36 #[serde(default)]
37 pub discourse: Vec<DiscourseConfig>,
38 #[serde(default)]
39 pub harden: HardenConfig,
40}
41
42#[derive(Debug, Serialize, Deserialize, Default, Clone)]
47pub struct HardenConfig {
48 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
50 pub new_user: Option<String>,
51 #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
53 pub ssh_port: Option<u64>,
54 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
56 pub docker_install_url: Option<String>,
57 #[serde(default)]
59 pub docker_rootless: Option<bool>,
60 #[serde(default)]
62 pub swap_size_gb: Option<u32>,
63 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
65 pub journald_max_use: Option<String>,
66 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
68 pub timezone: Option<String>,
69 #[serde(default)]
71 pub unattended_security_upgrades: Option<bool>,
72 #[serde(default)]
74 pub fail2ban: Option<bool>,
75 #[serde(default)]
77 pub mosh: Option<bool>,
78 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
81 pub sshd_ciphers: Option<String>,
82 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
85 pub sshd_kex: Option<String>,
86 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
89 pub sshd_macs: Option<String>,
90 #[serde(default)]
93 pub extra_ufw_allow: Option<Vec<String>>,
94}
95
96#[derive(Debug, Serialize, Deserialize, Default, Clone)]
98pub struct DiscourseConfig {
99 pub name: String,
100 pub baseurl: String,
101 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
102 pub fullname: Option<String>,
103 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
104 pub apikey: Option<String>,
105 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
106 pub api_username: Option<String>,
107 #[serde(default)]
108 pub tags: Option<Vec<String>>,
109 #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
110 pub changelog_topic_id: Option<u64>,
111 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
112 pub ssh_host: Option<String>,
113 #[serde(default)]
114 pub docker_rootless: Option<bool>,
115}
116
117pub fn load_config(path: &Path) -> Result<Config> {
119 if !path.exists() {
120 return Ok(Config::default());
121 }
122 let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
123 let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
124 warn_on_discourse_names(&config);
125 Ok(config)
126}
127
128pub fn save_config(path: &Path, config: &Config) -> Result<()> {
130 let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
131 write_config_file(path, raw.as_bytes())?;
132 Ok(())
133}
134
135#[cfg(unix)]
136fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
137 use std::io::Write;
138 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
139
140 let mut file = fs::OpenOptions::new()
141 .create(true)
142 .truncate(true)
143 .write(true)
144 .mode(0o600)
145 .open(path)
146 .with_context(|| format!("writing {}", path.display()))?;
147 file.write_all(raw)
148 .with_context(|| format!("writing {}", path.display()))?;
149
150 let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
151 let mode = metadata.permissions().mode() & 0o777;
152 if mode & 0o077 != 0 {
153 if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
154 eprintln!(
155 "Warning: unable to tighten permissions on {}: {}",
156 path.display(),
157 err
158 );
159 }
160 }
161 Ok(())
162}
163
164#[cfg(not(unix))]
165fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
166 fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
167 Ok(())
168}
169
170pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
172 config.discourse.iter().find(|d| d.name == name)
173}
174
175pub fn find_discourse_mut<'a>(
177 config: &'a mut Config,
178 name: &str,
179) -> Option<&'a mut DiscourseConfig> {
180 config.discourse.iter_mut().find(|d| d.name == name)
181}
182
183fn warn_on_discourse_names(config: &Config) {
184 for discourse in &config.discourse {
185 if discourse.name.chars().any(|ch| ch.is_whitespace()) {
186 eprintln!(
187 "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
188 discourse.name
189 );
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ConfigSource {
199 Flag(PathBuf),
201 EnvVar(PathBuf),
203 Discovered(PathBuf),
205 Default(PathBuf),
208}
209
210impl ConfigSource {
211 pub fn path(&self) -> &Path {
213 match self {
214 Self::Flag(p) | Self::EnvVar(p) | Self::Discovered(p) | Self::Default(p) => p,
215 }
216 }
217
218 pub fn label(&self) -> &'static str {
220 match self {
221 Self::Flag(_) => "via --config flag",
222 Self::EnvVar(_) => "via $DSC_CONFIG",
223 Self::Discovered(_) => "from search hierarchy",
224 Self::Default(_) => "default (no config found)",
225 }
226 }
227}
228
229pub fn resolve_config_source(flag: Option<PathBuf>) -> Result<ConfigSource> {
242 resolve_config_source_with_env(flag, |k| std::env::var_os(k))
243}
244
245fn resolve_config_source_with_env<F>(flag: Option<PathBuf>, env: F) -> Result<ConfigSource>
246where
247 F: Fn(&str) -> Option<OsString> + Copy,
248{
249 if let Some(path) = flag {
250 if !path.exists() {
251 return Err(anyhow!(
252 "config file not found: {} (specified via --config)",
253 path.display()
254 ));
255 }
256 return Ok(ConfigSource::Flag(path));
257 }
258
259 if let Some(raw) = env(ENV_CONFIG) {
260 let path = PathBuf::from(raw);
261 if !path.exists() {
262 return Err(anyhow!(
263 "config file not found: {} (specified via ${})",
264 path.display(),
265 ENV_CONFIG
266 ));
267 }
268 return Ok(ConfigSource::EnvVar(path));
269 }
270
271 let candidates = config_search_paths_with_env(env);
272 if let Some(found) = candidates.into_iter().find(|c| c.exists()) {
273 return Ok(ConfigSource::Discovered(found));
274 }
275
276 Ok(ConfigSource::Default(PathBuf::from("dsc.toml")))
277}
278
279pub fn config_search_paths() -> Vec<PathBuf> {
290 config_search_paths_with_env(|k| std::env::var_os(k))
291}
292
293fn config_search_paths_with_env<F>(env: F) -> Vec<PathBuf>
294where
295 F: Fn(&str) -> Option<OsString>,
296{
297 let mut candidates = vec![PathBuf::from("dsc.toml")];
298
299 let config_home: Option<PathBuf> = env(ENV_CONFIG_HOME)
301 .map(PathBuf::from)
302 .or_else(|| env("XDG_CONFIG_HOME").map(|x| PathBuf::from(x).join("dsc")))
303 .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config").join("dsc")));
304 if let Some(dir) = config_home {
305 candidates.push(dir.join("dsc.toml"));
306 }
307
308 #[cfg(unix)]
309 {
310 if let Some(xdg_config_dirs) = env("XDG_CONFIG_DIRS") {
311 for dir in std::env::split_paths(&xdg_config_dirs) {
312 candidates.push(dir.join("dsc").join("dsc.toml"));
313 }
314 } else {
315 candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
316 }
317 candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
318 candidates.push(PathBuf::from("/etc/dsc.toml"));
319 candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
320 }
321
322 candidates
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::collections::HashMap;
329 use std::ffi::{OsStr, OsString};
330 use std::path::PathBuf;
331
332 fn env_from<'a>(
334 pairs: &'a HashMap<&'static str, OsString>,
335 ) -> impl Fn(&str) -> Option<OsString> + Copy + 'a {
336 move |k: &str| pairs.get(k).cloned()
337 }
338
339 fn osstr<S: AsRef<OsStr>>(s: S) -> OsString {
340 s.as_ref().to_os_string()
341 }
342
343 #[test]
344 fn flag_wins_over_env_and_discovery() {
345 let dir = tempfile::tempdir().expect("tempdir");
346 let flag_file = dir.path().join("flag.toml");
347 let env_file = dir.path().join("env.toml");
348 std::fs::write(&flag_file, "").unwrap();
349 std::fs::write(&env_file, "").unwrap();
350
351 let mut env = HashMap::new();
352 env.insert(ENV_CONFIG, osstr(&env_file));
353 let source =
354 resolve_config_source_with_env(Some(flag_file.clone()), env_from(&env)).unwrap();
355 assert!(matches!(source, ConfigSource::Flag(_)));
356 assert_eq!(source.path(), flag_file);
357 }
358
359 #[test]
360 fn missing_flag_path_errors() {
361 let dir = tempfile::tempdir().expect("tempdir");
362 let missing = dir.path().join("nope.toml");
363 let env: HashMap<&'static str, OsString> = HashMap::new();
364 let err = resolve_config_source_with_env(Some(missing), env_from(&env)).unwrap_err();
365 assert!(err.to_string().contains("--config"));
366 }
367
368 #[test]
369 fn dsc_config_env_wins_over_discovery() {
370 let dir = tempfile::tempdir().expect("tempdir");
371 let env_file = dir.path().join("env.toml");
372 std::fs::write(&env_file, "").unwrap();
373 let home_dir = dir.path().join("home");
374 let dsc_dir = home_dir.join(".config").join("dsc");
375 std::fs::create_dir_all(&dsc_dir).unwrap();
376 std::fs::write(dsc_dir.join("dsc.toml"), "").unwrap();
377
378 let mut env = HashMap::new();
379 env.insert(ENV_CONFIG, osstr(&env_file));
380 env.insert("HOME", osstr(&home_dir));
381 let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
382 assert!(matches!(source, ConfigSource::EnvVar(_)));
383 assert_eq!(source.path(), env_file);
384 }
385
386 #[test]
387 fn missing_dsc_config_env_path_errors() {
388 let dir = tempfile::tempdir().expect("tempdir");
389 let missing = dir.path().join("missing.toml");
390 let mut env = HashMap::new();
391 env.insert(ENV_CONFIG, osstr(&missing));
392 let err = resolve_config_source_with_env(None, env_from(&env)).unwrap_err();
393 assert!(err.to_string().contains("$DSC_CONFIG"));
394 }
395
396 #[test]
397 fn dsc_config_home_redirects_step_4() {
398 let dir = tempfile::tempdir().expect("tempdir");
399 let custom_home = dir.path().join("custom");
400 std::fs::create_dir_all(&custom_home).unwrap();
401 std::fs::write(custom_home.join("dsc.toml"), "").unwrap();
402
403 let mut env = HashMap::new();
404 env.insert(ENV_CONFIG_HOME, osstr(&custom_home));
405 let candidates = config_search_paths_with_env(env_from(&env));
406
407 assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
409 assert_eq!(candidates[1], custom_home.join("dsc.toml"));
410 }
411
412 #[test]
413 fn unset_config_home_reproduces_home_config_dsc() {
414 let dir = tempfile::tempdir().expect("tempdir");
417 let home = dir.path().to_path_buf();
418 let mut env = HashMap::new();
419 env.insert("HOME", osstr(&home));
420 let candidates = config_search_paths_with_env(env_from(&env));
421 assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
422 assert_eq!(
423 candidates[1],
424 home.join(".config").join("dsc").join("dsc.toml")
425 );
426 }
427
428 #[test]
429 fn xdg_config_home_default_used_when_dsc_config_home_unset() {
430 let dir = tempfile::tempdir().expect("tempdir");
433 let xdg = dir.path().join("xdg");
434 let mut env = HashMap::new();
435 env.insert("XDG_CONFIG_HOME", osstr(&xdg));
436 let candidates = config_search_paths_with_env(env_from(&env));
437 assert_eq!(candidates[1], xdg.join("dsc").join("dsc.toml"));
438 }
439
440 #[test]
441 fn dsc_config_home_overrides_xdg_config_home() {
442 let dir = tempfile::tempdir().expect("tempdir");
443 let xdg = dir.path().join("xdg");
444 let dsc_home = dir.path().join("custom_dsc_home");
445 let mut env = HashMap::new();
446 env.insert("XDG_CONFIG_HOME", osstr(&xdg));
447 env.insert(ENV_CONFIG_HOME, osstr(&dsc_home));
448 let candidates = config_search_paths_with_env(env_from(&env));
449 assert_eq!(candidates[1], dsc_home.join("dsc.toml"));
450 }
451
452 #[test]
453 fn unset_everything_resolution_matches_legacy_order() {
454 let env: HashMap<&'static str, OsString> = HashMap::new();
460 let candidates = config_search_paths_with_env(env_from(&env));
461 assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
462 #[cfg(unix)]
463 {
464 assert!(candidates.contains(&PathBuf::from("/etc/xdg/dsc/dsc.toml")));
465 assert!(candidates.contains(&PathBuf::from("/etc/dsc/dsc.toml")));
466 assert!(candidates.contains(&PathBuf::from("/etc/dsc.toml")));
467 assert!(candidates.contains(&PathBuf::from("/usr/local/etc/dsc.toml")));
468 }
469 }
470
471 #[test]
472 fn no_config_anywhere_returns_default() {
473 let dir = tempfile::tempdir().expect("tempdir");
474 let mut env = HashMap::new();
479 env.insert("HOME", osstr(dir.path()));
480 if !PathBuf::from("dsc.toml").exists() {
482 let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
483 assert!(matches!(source, ConfigSource::Default(_)));
484 }
485 }
486}