1use anyhow::{Context, Result};
2use serde::de::Deserializer;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
8where
9 D: Deserializer<'de>,
10{
11 let value = Option::<String>::deserialize(deserializer)?;
12 Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
13}
14
15fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
16where
17 D: Deserializer<'de>,
18{
19 let value = Option::<u64>::deserialize(deserializer)?;
20 Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
21}
22
23#[derive(Debug, Serialize, Deserialize, Default, Clone)]
25pub struct Config {
26 #[serde(default)]
27 pub discourse: Vec<DiscourseConfig>,
28 #[serde(default)]
29 pub harden: HardenConfig,
30}
31
32#[derive(Debug, Serialize, Deserialize, Default, Clone)]
37pub struct HardenConfig {
38 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
40 pub new_user: Option<String>,
41 #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
43 pub ssh_port: Option<u64>,
44 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
46 pub docker_install_url: Option<String>,
47 #[serde(default)]
49 pub docker_rootless: Option<bool>,
50 #[serde(default)]
52 pub swap_size_gb: Option<u32>,
53 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
55 pub journald_max_use: Option<String>,
56 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
58 pub timezone: Option<String>,
59 #[serde(default)]
61 pub unattended_security_upgrades: Option<bool>,
62 #[serde(default)]
64 pub fail2ban: Option<bool>,
65 #[serde(default)]
67 pub mosh: Option<bool>,
68 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
70 pub sshd_ciphers: Option<String>,
71 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
73 pub sshd_kex: Option<String>,
74 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
76 pub sshd_macs: Option<String>,
77 #[serde(default)]
80 pub extra_ufw_allow: Option<Vec<String>>,
81}
82
83#[derive(Debug, Serialize, Deserialize, Default, Clone)]
85pub struct DiscourseConfig {
86 pub name: String,
87 pub baseurl: String,
88 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
89 pub fullname: Option<String>,
90 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
91 pub apikey: Option<String>,
92 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
93 pub api_username: Option<String>,
94 #[serde(default)]
95 pub tags: Option<Vec<String>>,
96 #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
97 pub changelog_topic_id: Option<u64>,
98 #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
99 pub ssh_host: Option<String>,
100}
101
102pub fn load_config(path: &Path) -> Result<Config> {
104 if !path.exists() {
105 return Ok(Config::default());
106 }
107 let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
108 let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
109 warn_on_discourse_names(&config);
110 Ok(config)
111}
112
113pub fn save_config(path: &Path, config: &Config) -> Result<()> {
115 let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
116 write_config_file(path, raw.as_bytes())?;
117 Ok(())
118}
119
120#[cfg(unix)]
121fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
122 use std::io::Write;
123 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
124
125 let mut file = fs::OpenOptions::new()
126 .create(true)
127 .truncate(true)
128 .write(true)
129 .mode(0o600)
130 .open(path)
131 .with_context(|| format!("writing {}", path.display()))?;
132 file.write_all(raw)
133 .with_context(|| format!("writing {}", path.display()))?;
134
135 let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
136 let mode = metadata.permissions().mode() & 0o777;
137 if mode & 0o077 != 0 {
138 if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
139 eprintln!(
140 "Warning: unable to tighten permissions on {}: {}",
141 path.display(),
142 err
143 );
144 }
145 }
146 Ok(())
147}
148
149#[cfg(not(unix))]
150fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
151 fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
152 Ok(())
153}
154
155pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
157 config.discourse.iter().find(|d| d.name == name)
158}
159
160pub fn find_discourse_mut<'a>(
162 config: &'a mut Config,
163 name: &str,
164) -> Option<&'a mut DiscourseConfig> {
165 config.discourse.iter_mut().find(|d| d.name == name)
166}
167
168fn warn_on_discourse_names(config: &Config) {
169 for discourse in &config.discourse {
170 if discourse.name.chars().any(|ch| ch.is_whitespace()) {
171 eprintln!(
172 "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
173 discourse.name
174 );
175 }
176 }
177}
178
179pub fn resolve_default_config_path() -> PathBuf {
188 let local = PathBuf::from("dsc.toml");
189 let mut candidates = vec![local.clone()];
190
191 if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
192 candidates.push(PathBuf::from(xdg_config_home).join("dsc").join("dsc.toml"));
193 } else if let Some(home) = std::env::var_os("HOME") {
194 candidates.push(
195 PathBuf::from(home)
196 .join(".config")
197 .join("dsc")
198 .join("dsc.toml"),
199 );
200 }
201
202 #[cfg(unix)]
203 {
204 if let Some(xdg_config_dirs) = std::env::var_os("XDG_CONFIG_DIRS") {
205 for dir in std::env::split_paths(&xdg_config_dirs) {
206 candidates.push(dir.join("dsc").join("dsc.toml"));
207 }
208 } else {
209 candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
210 }
211 candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
212 candidates.push(PathBuf::from("/etc/dsc.toml"));
213 candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
214 }
215
216 first_existing_config_path(candidates).unwrap_or(local)
217}
218
219fn first_existing_config_path<I>(candidates: I) -> Option<PathBuf>
220where
221 I: IntoIterator<Item = PathBuf>,
222{
223 candidates.into_iter().find(|candidate| candidate.exists())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::first_existing_config_path;
229 use std::path::PathBuf;
230
231 #[test]
232 fn returns_first_existing_path_in_order() {
233 let dir = tempfile::tempdir().expect("tempdir");
234 let first = dir.path().join("first.toml");
235 let second = dir.path().join("second.toml");
236 std::fs::write(&second, "").expect("write");
237 std::fs::write(&first, "").expect("write");
238
239 let selected = first_existing_config_path(vec![first.clone(), second]).expect("selected");
240 assert_eq!(selected, first);
241 }
242
243 #[test]
244 fn returns_none_when_no_candidates_exist() {
245 let dir = tempfile::tempdir().expect("tempdir");
246 let missing = dir.path().join("missing.toml");
247 let selected =
248 first_existing_config_path(vec![missing, PathBuf::from("/definitely/missing")]);
249 assert!(selected.is_none());
250 }
251}