Skip to main content

rns_git/
config.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::logging::DEFAULT_LOG_LEVEL;
6use crate::util::parse_hex_16;
7use crate::Result;
8
9#[derive(Debug, Clone)]
10pub struct ServerConfig {
11    pub dir: PathBuf,
12    pub reticulum_dir: Option<PathBuf>,
13    pub repositories_dir: PathBuf,
14    pub identity_path: PathBuf,
15    pub client_identity_path: PathBuf,
16    pub node_name: String,
17    pub announce_interval_secs: u64,
18    pub serve_nomadnet: bool,
19    pub templates_dir: PathBuf,
20    pub unicode_icons: bool,
21    pub record_stats: bool,
22    pub stats_ignore_identities: Vec<[u8; 16]>,
23    pub allow_read: Vec<String>,
24    pub allow_write: Vec<String>,
25    pub allow_create: Vec<String>,
26    pub allow_stats: Vec<String>,
27    pub allow_release: Vec<String>,
28    pub allow_interact: Vec<String>,
29    pub allow_admin: Vec<String>,
30    pub log_level: u8,
31}
32
33#[derive(Debug, Clone)]
34pub struct ClientConfig {
35    pub dir: PathBuf,
36    pub reticulum_dir: Option<PathBuf>,
37    pub identity_path: PathBuf,
38    pub connect_timeout_secs: u64,
39    pub request_timeout_secs: u64,
40    pub log_level: u8,
41}
42
43impl ServerConfig {
44    pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
45        fs::create_dir_all(&dir)?;
46        let path = dir.join("server_config");
47        if !path.exists() {
48            fs::write(&path, default_server_config())?;
49            return Ok((Self::defaults(dir, reticulum_dir), true));
50        }
51        let ini = parse_ini(&fs::read_to_string(&path)?)?;
52        let mut cfg = Self::defaults(dir, reticulum_dir);
53        if let Some(v) = get(&ini, "repositories", "path") {
54            cfg.repositories_dir = resolve_path(&cfg.dir, v);
55        }
56        if let Some(v) = get(&ini, "rngit", "identity") {
57            cfg.identity_path = resolve_path(&cfg.dir, v);
58        }
59        if let Some(v) = get(&ini, "rngit", "client_identity") {
60            cfg.client_identity_path = resolve_path(&cfg.dir, v);
61        }
62        if let Some(v) = get(&ini, "rngit", "node_name") {
63            cfg.node_name = v.to_string();
64        }
65        if let Some(v) = get(&ini, "rngit", "announce_interval") {
66            cfg.announce_interval_secs = v.parse().unwrap_or(cfg.announce_interval_secs);
67        }
68        if let Some(v) = get(&ini, "rngit", "record_stats") {
69            cfg.record_stats = parse_bool(v, cfg.record_stats);
70        }
71        if let Some(v) = get(&ini, "rngit", "stats_ignore_identities") {
72            cfg.stats_ignore_identities = split_list(v)
73                .into_iter()
74                .filter_map(|value| parse_hex_16(&value).ok())
75                .collect();
76        }
77        if let Some(v) = get(&ini, "pages", "serve_nomadnet") {
78            cfg.serve_nomadnet = parse_bool(v, cfg.serve_nomadnet);
79        }
80        if let Some(v) = get(&ini, "pages", "templates_dir") {
81            cfg.templates_dir = resolve_path(&cfg.dir, v);
82        }
83        if let Some(v) = get(&ini, "pages", "unicode_icons") {
84            cfg.unicode_icons = parse_bool(v, cfg.unicode_icons);
85        }
86        if let Some(v) = get(&ini, "logging", "loglevel") {
87            cfg.log_level = parse_log_level(v, cfg.log_level);
88        }
89        cfg.allow_read = split_list(get(&ini, "access", "read").unwrap_or("all"));
90        cfg.allow_write = split_list(get(&ini, "access", "write").unwrap_or("none"));
91        cfg.allow_create = split_list(get(&ini, "access", "create").unwrap_or("none"));
92        cfg.allow_stats = split_list(get(&ini, "access", "stats").unwrap_or("none"));
93        cfg.allow_release = split_list(get(&ini, "access", "release").unwrap_or("none"));
94        cfg.allow_interact = split_list(get(&ini, "access", "interact").unwrap_or("none"));
95        cfg.allow_admin = split_list(get(&ini, "access", "admin").unwrap_or("none"));
96        Ok((cfg, false))
97    }
98
99    fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
100        Self {
101            repositories_dir: dir.join("repositories"),
102            identity_path: dir.join("repositories_identity"),
103            client_identity_path: dir.join("client_identity"),
104            node_name: "Anonymous Git Node".to_string(),
105            dir: dir.clone(),
106            reticulum_dir,
107            announce_interval_secs: 300,
108            serve_nomadnet: false,
109            templates_dir: dir.join("templates"),
110            unicode_icons: false,
111            record_stats: false,
112            stats_ignore_identities: Vec::new(),
113            allow_read: vec!["all".to_string()],
114            allow_write: vec!["none".to_string()],
115            allow_create: vec!["none".to_string()],
116            allow_stats: vec!["none".to_string()],
117            allow_release: vec!["none".to_string()],
118            allow_interact: vec!["none".to_string()],
119            allow_admin: vec!["none".to_string()],
120            log_level: DEFAULT_LOG_LEVEL,
121        }
122    }
123}
124
125impl ClientConfig {
126    pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
127        fs::create_dir_all(&dir)?;
128        let path = dir.join("client_config");
129        if !path.exists() {
130            fs::write(&path, default_client_config())?;
131            return Ok((Self::defaults(dir, reticulum_dir), true));
132        }
133        let ini = parse_ini(&fs::read_to_string(&path)?)?;
134        let mut cfg = Self::defaults(dir, reticulum_dir);
135        if let Some(v) = get(&ini, "client", "identity") {
136            cfg.identity_path = resolve_path(&cfg.dir, v);
137        }
138        if let Some(v) = get(&ini, "client", "connect_timeout") {
139            cfg.connect_timeout_secs = v.parse().unwrap_or(cfg.connect_timeout_secs);
140        }
141        if let Some(v) = get(&ini, "client", "request_timeout") {
142            cfg.request_timeout_secs = v.parse().unwrap_or(cfg.request_timeout_secs);
143        }
144        if let Some(v) = get(&ini, "logging", "loglevel") {
145            cfg.log_level = parse_log_level(v, cfg.log_level);
146        }
147        Ok((cfg, false))
148    }
149
150    fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
151        Self {
152            identity_path: dir.join("client_identity"),
153            dir,
154            reticulum_dir,
155            connect_timeout_secs: 30,
156            request_timeout_secs: 300,
157            log_level: DEFAULT_LOG_LEVEL,
158        }
159    }
160}
161
162type Ini = BTreeMap<String, BTreeMap<String, String>>;
163
164fn parse_ini(input: &str) -> Result<Ini> {
165    let mut section = "rngit".to_string();
166    let mut out = Ini::new();
167    for raw in input.lines() {
168        let line = raw.split('#').next().unwrap_or("").trim();
169        if line.is_empty() {
170            continue;
171        }
172        if line.starts_with('[') && line.ends_with(']') {
173            section = line[1..line.len() - 1].trim().to_string();
174            continue;
175        }
176        if let Some((key, value)) = line.split_once('=') {
177            out.entry(section.clone())
178                .or_default()
179                .insert(key.trim().to_string(), value.trim().to_string());
180        }
181    }
182    Ok(out)
183}
184
185fn get<'a>(ini: &'a Ini, section: &str, key: &str) -> Option<&'a str> {
186    ini.get(section)?.get(key).map(String::as_str)
187}
188
189fn split_list(value: &str) -> Vec<String> {
190    value
191        .split(',')
192        .map(str::trim)
193        .filter(|v| !v.is_empty())
194        .map(ToOwned::to_owned)
195        .collect()
196}
197
198fn parse_log_level(value: &str, fallback: u8) -> u8 {
199    value
200        .parse::<u8>()
201        .map(|level| level.min(7))
202        .unwrap_or(fallback)
203}
204
205fn parse_bool(value: &str, fallback: bool) -> bool {
206    match value.trim().to_ascii_lowercase().as_str() {
207        "1" | "true" | "yes" | "on" => true,
208        "0" | "false" | "no" | "off" => false,
209        _ => fallback,
210    }
211}
212
213fn expand_home(value: &str) -> PathBuf {
214    if let Some(rest) = value.strip_prefix("~/") {
215        if let Some(home) = std::env::var_os("HOME") {
216            return PathBuf::from(home).join(rest);
217        }
218    }
219    PathBuf::from(value)
220}
221
222fn resolve_path(base: &Path, value: &str) -> PathBuf {
223    let path = expand_home(value);
224    if path.is_absolute() {
225        path
226    } else {
227        base.join(path)
228    }
229}
230
231fn default_server_config() -> &'static str {
232    "[rngit]\nannounce_interval = 300\nidentity = repositories_identity\nclient_identity = client_identity\n# node_name = Anonymous Git Node\n# record_stats = no\n# stats_ignore_identities = 00112233445566778899aabbccddeeff\n\n[repositories]\npath = repositories\n\n[access]\nread = all\nwrite = none\ncreate = none\nstats = none\nrelease = none\ninteract = none\nadmin = none\n\n[pages]\n# serve_nomadnet = no\n# templates_dir = templates\n# unicode_icons = no\n\n[logging]\nloglevel = 4\n"
233}
234
235fn default_client_config() -> &'static str {
236    "[client]\nidentity = client_identity\nconnect_timeout = 30\nrequest_timeout = 300\n\n[logging]\nloglevel = 4\n"
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parses_sections_and_lists() {
245        let ini = parse_ini("[access]\nread = all, 0011\nwrite = none\ncreate = all\n").unwrap();
246        assert_eq!(get(&ini, "access", "write"), Some("none"));
247        assert_eq!(get(&ini, "access", "create"), Some("all"));
248        assert_eq!(split_list(get(&ini, "access", "read").unwrap()).len(), 2);
249    }
250
251    #[test]
252    fn parses_and_clamps_log_level() {
253        let tmp = tempfile::tempdir().unwrap();
254        fs::write(
255            tmp.path().join("client_config"),
256            "[client]\nrequest_timeout = 5\n[logging]\nloglevel = 99\n",
257        )
258        .unwrap();
259        let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
260        assert!(!created);
261        assert_eq!(cfg.log_level, 7);
262    }
263
264    #[test]
265    fn creates_default_server_config_once() {
266        let tmp = tempfile::tempdir().unwrap();
267        let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
268        assert!(created);
269        let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
270        assert!(!created);
271    }
272
273    #[test]
274    fn parses_nomadnet_page_config() {
275        let tmp = tempfile::tempdir().unwrap();
276        fs::write(
277            tmp.path().join("server_config"),
278            "[rngit]\nnode_name = Public Git Node\n[pages]\nserve_nomadnet = yes\ntemplates_dir = custom_templates\nunicode_icons = yes\n",
279        )
280        .unwrap();
281        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
282        assert!(!created);
283        assert_eq!(cfg.node_name, "Public Git Node");
284        assert!(cfg.serve_nomadnet);
285        assert_eq!(cfg.templates_dir, tmp.path().join("custom_templates"));
286        assert!(cfg.unicode_icons);
287    }
288
289    #[test]
290    fn parses_stats_config_and_permissions() {
291        let tmp = tempfile::tempdir().unwrap();
292        fs::write(
293            tmp.path().join("server_config"),
294            "[rngit]\nrecord_stats = yes\nstats_ignore_identities = 00112233445566778899aabbccddeeff\n[access]\nstats = all, 0102030405060708090a0b0c0d0e0f10\n",
295        )
296        .unwrap();
297
298        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
299        assert!(!created);
300        assert!(cfg.record_stats);
301        assert_eq!(
302            cfg.stats_ignore_identities,
303            vec![[
304                0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
305                0xee, 0xff
306            ]]
307        );
308        assert_eq!(
309            cfg.allow_stats,
310            vec![
311                "all".to_string(),
312                "0102030405060708090a0b0c0d0e0f10".to_string()
313            ]
314        );
315    }
316
317    #[test]
318    fn parses_release_permission_config() {
319        let tmp = tempfile::tempdir().unwrap();
320        fs::write(
321            tmp.path().join("server_config"),
322            "[access]\nrelease = all, 0102030405060708090a0b0c0d0e0f10\ninteract = all\nadmin = 00112233445566778899aabbccddeeff\n",
323        )
324        .unwrap();
325
326        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
327        assert!(!created);
328        assert_eq!(
329            cfg.allow_release,
330            vec![
331                "all".to_string(),
332                "0102030405060708090a0b0c0d0e0f10".to_string()
333            ]
334        );
335        assert_eq!(cfg.allow_interact, vec!["all".to_string()]);
336        assert_eq!(
337            cfg.allow_admin,
338            vec!["00112233445566778899aabbccddeeff".to_string()]
339        );
340    }
341
342    #[test]
343    fn missing_pages_section_keeps_nomadnet_disabled() {
344        let tmp = tempfile::tempdir().unwrap();
345        fs::write(tmp.path().join("server_config"), "[rngit]\n").unwrap();
346        let (cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
347        assert!(!created);
348        assert_eq!(cfg.node_name, "Anonymous Git Node");
349        assert!(!cfg.serve_nomadnet);
350        assert_eq!(cfg.templates_dir, tmp.path().join("templates"));
351        assert!(!cfg.unicode_icons);
352        assert!(!cfg.record_stats);
353        assert!(cfg.stats_ignore_identities.is_empty());
354        assert_eq!(cfg.allow_stats, vec!["none".to_string()]);
355        assert_eq!(cfg.allow_release, vec!["none".to_string()]);
356        assert_eq!(cfg.allow_interact, vec!["none".to_string()]);
357        assert_eq!(cfg.allow_admin, vec!["none".to_string()]);
358    }
359}