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::Result;
7
8#[derive(Debug, Clone)]
9pub struct ServerConfig {
10    pub dir: PathBuf,
11    pub reticulum_dir: Option<PathBuf>,
12    pub repositories_dir: PathBuf,
13    pub identity_path: PathBuf,
14    pub client_identity_path: PathBuf,
15    pub announce_interval_secs: u64,
16    pub allow_read: Vec<String>,
17    pub allow_write: Vec<String>,
18    pub log_level: u8,
19}
20
21#[derive(Debug, Clone)]
22pub struct ClientConfig {
23    pub dir: PathBuf,
24    pub reticulum_dir: Option<PathBuf>,
25    pub identity_path: PathBuf,
26    pub connect_timeout_secs: u64,
27    pub request_timeout_secs: u64,
28    pub log_level: u8,
29}
30
31impl ServerConfig {
32    pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
33        fs::create_dir_all(&dir)?;
34        let path = dir.join("server_config");
35        if !path.exists() {
36            fs::write(&path, default_server_config())?;
37            return Ok((Self::defaults(dir, reticulum_dir), true));
38        }
39        let ini = parse_ini(&fs::read_to_string(&path)?)?;
40        let mut cfg = Self::defaults(dir, reticulum_dir);
41        if let Some(v) = get(&ini, "repositories", "path") {
42            cfg.repositories_dir = resolve_path(&cfg.dir, v);
43        }
44        if let Some(v) = get(&ini, "rngit", "identity") {
45            cfg.identity_path = resolve_path(&cfg.dir, v);
46        }
47        if let Some(v) = get(&ini, "rngit", "client_identity") {
48            cfg.client_identity_path = resolve_path(&cfg.dir, v);
49        }
50        if let Some(v) = get(&ini, "rngit", "announce_interval") {
51            cfg.announce_interval_secs = v.parse().unwrap_or(cfg.announce_interval_secs);
52        }
53        if let Some(v) = get(&ini, "logging", "loglevel") {
54            cfg.log_level = parse_log_level(v, cfg.log_level);
55        }
56        cfg.allow_read = split_list(get(&ini, "access", "read").unwrap_or("all"));
57        cfg.allow_write = split_list(get(&ini, "access", "write").unwrap_or("none"));
58        Ok((cfg, false))
59    }
60
61    fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
62        Self {
63            repositories_dir: dir.join("repositories"),
64            identity_path: dir.join("repositories_identity"),
65            client_identity_path: dir.join("client_identity"),
66            dir,
67            reticulum_dir,
68            announce_interval_secs: 300,
69            allow_read: vec!["all".to_string()],
70            allow_write: vec!["none".to_string()],
71            log_level: DEFAULT_LOG_LEVEL,
72        }
73    }
74}
75
76impl ClientConfig {
77    pub fn load_or_create(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Result<(Self, bool)> {
78        fs::create_dir_all(&dir)?;
79        let path = dir.join("client_config");
80        if !path.exists() {
81            fs::write(&path, default_client_config())?;
82            return Ok((Self::defaults(dir, reticulum_dir), true));
83        }
84        let ini = parse_ini(&fs::read_to_string(&path)?)?;
85        let mut cfg = Self::defaults(dir, reticulum_dir);
86        if let Some(v) = get(&ini, "client", "identity") {
87            cfg.identity_path = resolve_path(&cfg.dir, v);
88        }
89        if let Some(v) = get(&ini, "client", "connect_timeout") {
90            cfg.connect_timeout_secs = v.parse().unwrap_or(cfg.connect_timeout_secs);
91        }
92        if let Some(v) = get(&ini, "client", "request_timeout") {
93            cfg.request_timeout_secs = v.parse().unwrap_or(cfg.request_timeout_secs);
94        }
95        if let Some(v) = get(&ini, "logging", "loglevel") {
96            cfg.log_level = parse_log_level(v, cfg.log_level);
97        }
98        Ok((cfg, false))
99    }
100
101    fn defaults(dir: PathBuf, reticulum_dir: Option<PathBuf>) -> Self {
102        Self {
103            identity_path: dir.join("client_identity"),
104            dir,
105            reticulum_dir,
106            connect_timeout_secs: 30,
107            request_timeout_secs: 300,
108            log_level: DEFAULT_LOG_LEVEL,
109        }
110    }
111}
112
113type Ini = BTreeMap<String, BTreeMap<String, String>>;
114
115fn parse_ini(input: &str) -> Result<Ini> {
116    let mut section = "rngit".to_string();
117    let mut out = Ini::new();
118    for raw in input.lines() {
119        let line = raw.split('#').next().unwrap_or("").trim();
120        if line.is_empty() {
121            continue;
122        }
123        if line.starts_with('[') && line.ends_with(']') {
124            section = line[1..line.len() - 1].trim().to_string();
125            continue;
126        }
127        if let Some((key, value)) = line.split_once('=') {
128            out.entry(section.clone())
129                .or_default()
130                .insert(key.trim().to_string(), value.trim().to_string());
131        }
132    }
133    Ok(out)
134}
135
136fn get<'a>(ini: &'a Ini, section: &str, key: &str) -> Option<&'a str> {
137    ini.get(section)?.get(key).map(String::as_str)
138}
139
140fn split_list(value: &str) -> Vec<String> {
141    value
142        .split(',')
143        .map(str::trim)
144        .filter(|v| !v.is_empty())
145        .map(ToOwned::to_owned)
146        .collect()
147}
148
149fn parse_log_level(value: &str, fallback: u8) -> u8 {
150    value
151        .parse::<u8>()
152        .map(|level| level.min(7))
153        .unwrap_or(fallback)
154}
155
156fn expand_home(value: &str) -> PathBuf {
157    if let Some(rest) = value.strip_prefix("~/") {
158        if let Some(home) = std::env::var_os("HOME") {
159            return PathBuf::from(home).join(rest);
160        }
161    }
162    PathBuf::from(value)
163}
164
165fn resolve_path(base: &Path, value: &str) -> PathBuf {
166    let path = expand_home(value);
167    if path.is_absolute() {
168        path
169    } else {
170        base.join(path)
171    }
172}
173
174fn default_server_config() -> &'static str {
175    "[rngit]\nannounce_interval = 300\nidentity = repositories_identity\nclient_identity = client_identity\n\n[repositories]\npath = repositories\n\n[access]\nread = all\nwrite = none\n\n[logging]\nloglevel = 4\n"
176}
177
178fn default_client_config() -> &'static str {
179    "[client]\nidentity = client_identity\nconnect_timeout = 30\nrequest_timeout = 300\n\n[logging]\nloglevel = 4\n"
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn parses_sections_and_lists() {
188        let ini = parse_ini("[access]\nread = all, 0011\nwrite = none\n").unwrap();
189        assert_eq!(get(&ini, "access", "write"), Some("none"));
190        assert_eq!(split_list(get(&ini, "access", "read").unwrap()).len(), 2);
191    }
192
193    #[test]
194    fn parses_and_clamps_log_level() {
195        let tmp = tempfile::tempdir().unwrap();
196        fs::write(
197            tmp.path().join("client_config"),
198            "[client]\nrequest_timeout = 5\n[logging]\nloglevel = 99\n",
199        )
200        .unwrap();
201        let (cfg, created) = ClientConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
202        assert!(!created);
203        assert_eq!(cfg.log_level, 7);
204    }
205
206    #[test]
207    fn creates_default_server_config_once() {
208        let tmp = tempfile::tempdir().unwrap();
209        let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
210        assert!(created);
211        let (_cfg, created) = ServerConfig::load_or_create(tmp.path().to_path_buf(), None).unwrap();
212        assert!(!created);
213    }
214}