huddle_core/config.rs
1use std::path::PathBuf;
2
3pub fn data_dir() -> PathBuf {
4 let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
5 base.join("huddle")
6}
7
8/// Phase D: location of the user's optional config file. We use
9/// `dirs::config_dir()` rather than `data_dir()` so this lives in the
10/// platform-appropriate "preferences" directory (macOS
11/// `~/Library/Application Support`, Linux `~/.config`, Windows
12/// `%APPDATA%`). Doesn't have to exist — `load_relays` returns an
13/// empty list if absent.
14pub fn config_path() -> PathBuf {
15 let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
16 base.join("huddle").join("config.toml")
17}
18
19/// Phase D: parse the relay multiaddr list from the config file. The
20/// documented form (README + MANUAL_TESTING §14) is a top-level array:
21///
22/// ```toml
23/// relays = [
24/// "/dns4/relay.example.com/tcp/4001/p2p/12D3Koo...",
25/// ]
26/// ```
27///
28/// huddle 0.7.12: the parser now honors exactly that. Pre-0.7.12 it
29/// required an undocumented `[network]` section header AND only parsed a
30/// single-line array, so the documented header-less, multi-line form
31/// silently produced zero relays — the `config.toml` path to cross-
32/// internet reach was a no-op. Now no header is required (a `relays`
33/// entry is accepted whether or not it sits under a section), the array
34/// may span multiple lines, a single-line `relays = ["a", "b"]` and a
35/// bare scalar `relays = "a"` both work, and trailing `# comments` are
36/// stripped. Returns an empty Vec if the file doesn't exist or has no
37/// relays entry.
38pub fn load_relays() -> Option<Vec<String>> {
39 let path = config_path();
40 let body = std::fs::read_to_string(&path).ok()?;
41 Some(parse_relays(&body))
42}
43
44/// Pure relay-list extraction, split out from `load_relays` so it can be
45/// unit-tested without touching the filesystem.
46fn parse_relays(body: &str) -> Vec<String> {
47 let mut out: Vec<String> = Vec::new();
48 let mut in_array = false;
49 for raw in body.lines() {
50 let line = strip_inline_comment(raw).trim();
51 if line.is_empty() {
52 continue;
53 }
54 if in_array {
55 // Inside a multi-line `relays = [ ... ]`. Collect quoted
56 // entries until the closing `]`.
57 let (segment, closed) = match line.find(']') {
58 Some(idx) => (&line[..idx], true),
59 None => (line, false),
60 };
61 collect_relay_items(segment, &mut out);
62 if closed {
63 in_array = false;
64 }
65 continue;
66 }
67 // Outside an array the only key we care about is `relays`.
68 // Section headers (`[network]`) and unrelated keys fall through
69 // — we accept a `relays` entry whether or not it sits under a
70 // section, matching the header-less documented form.
71 let rest = match line.strip_prefix("relays") {
72 Some(r) => r.trim_start(),
73 None => continue,
74 };
75 let rest = match rest.strip_prefix('=') {
76 Some(r) => r.trim(),
77 None => continue, // a key like `relays_enabled` — not ours
78 };
79 match rest.strip_prefix('[') {
80 // Array form, single- or multi-line.
81 Some(after_open) => match after_open.find(']') {
82 Some(idx) => collect_relay_items(&after_open[..idx], &mut out),
83 None => {
84 collect_relay_items(after_open, &mut out);
85 in_array = true;
86 }
87 },
88 // Bare scalar form: `relays = "addr"`.
89 None => {
90 let item = rest.trim_matches('"').trim_matches('\'');
91 if !item.is_empty() {
92 out.push(item.to_string());
93 }
94 }
95 }
96 }
97 out
98}
99
100/// Strip a `#` comment from a config line. Multiaddrs never contain `#`,
101/// so cutting at the first one is safe for the relays value space and
102/// matches TOML comment semantics.
103fn strip_inline_comment(line: &str) -> &str {
104 match line.find('#') {
105 Some(idx) => &line[..idx],
106 None => line,
107 }
108}
109
110/// Split a comma-separated array segment into trimmed, unquoted relay
111/// entries, dropping empties.
112fn collect_relay_items(segment: &str, out: &mut Vec<String>) {
113 for item in segment.split(',') {
114 let item = item.trim().trim_matches('"').trim_matches('\'');
115 if !item.is_empty() {
116 out.push(item.to_string());
117 }
118 }
119}
120
121/// huddle 0.8: optional override for the centralized server (a Tor-onion
122/// relay) WebSocket URL, read from `config.toml`:
123///
124/// ```toml
125/// server_url = "ws://<your-onion>.onion:80/ws"
126/// ```
127///
128/// Precedence is resolved by the caller (`main.rs`): the `--server` CLI
129/// flag wins, then this config value, then the baked-in default onion.
130/// So you can repoint the client at a different relay without recompiling
131/// or retyping a flag every launch. Returns `None` if absent.
132pub fn server_url() -> Option<String> {
133 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "server_url")
134}
135
136/// huddle 0.8: optional override for the local Tor SOCKS5 proxy address
137/// used to reach `.onion` server URLs (default `127.0.0.1:9050`). Set in
138/// `config.toml` as `tor_socks = "127.0.0.1:9150"` — e.g. to use the Tor
139/// Browser bundle's port. `--tor-socks` overrides this. `None` if absent.
140pub fn tor_socks() -> Option<String> {
141 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "tor_socks")
142}
143
144/// huddle 1.0: optional clearnet relay URL — a `ws://<ip>:<port>/ws` or
145/// `wss://host/ws` door onto the SAME relay backend as the onion. Lets users
146/// behind a VPN (or where Tor is blocked) reach the relay directly and fast.
147/// The scheme decides which clearnet door (plain / TLS) is used.
148///
149/// ```toml
150/// clearnet_url = "ws://203.0.113.7:8787/ws"
151/// ```
152/// `--clearnet-server` overrides this. `None` if absent.
153pub fn clearnet_url() -> Option<String> {
154 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "clearnet_url")
155}
156
157/// huddle 1.0: optional Tor bridge line for the bridge door (to reach Tor
158/// where it's blocked). With the `arti` build this is passed to the embedded
159/// Tor; otherwise it documents that your system Tor should carry this bridge.
160///
161/// ```toml
162/// tor_bridge = "obfs4 1.2.3.4:443 <FINGERPRINT> cert=... iat-mode=0"
163/// ```
164/// `--tor-bridge` overrides this. `None` if absent.
165pub fn tor_bridge() -> Option<String> {
166 parse_scalar(&std::fs::read_to_string(config_path()).ok()?, "tor_bridge")
167}
168
169/// Extract a top-level `key = "value"` string from a config body. Honors
170/// the same header-less, inline-comment-stripping conventions as
171/// `parse_relays`. Section headers and unrelated keys fall through.
172/// Returns the first match's unquoted value, or `None`.
173fn parse_scalar(body: &str, key: &str) -> Option<String> {
174 for raw in body.lines() {
175 let line = strip_inline_comment(raw).trim();
176 let rest = match line.strip_prefix(key) {
177 Some(r) => r.trim_start(),
178 None => continue,
179 };
180 // Guard against prefix collisions (`server_url_backup`): the next
181 // char after the key must begin an assignment.
182 let rest = match rest.strip_prefix('=') {
183 Some(r) => r.trim(),
184 None => continue,
185 };
186 let val = rest.trim_matches('"').trim_matches('\'').trim();
187 if !val.is_empty() {
188 return Some(val.to_string());
189 }
190 }
191 None
192}
193
194pub fn db_path() -> PathBuf {
195 data_dir().join("huddle.db")
196}
197
198pub fn log_path() -> PathBuf {
199 data_dir().join("huddle.log")
200}
201
202pub fn ensure_data_dir() -> std::io::Result<()> {
203 std::fs::create_dir_all(data_dir())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn data_dir_is_inside_huddle_directory() {
212 let dir = data_dir();
213 assert!(dir.ends_with("huddle") || dir.to_string_lossy().contains("huddle"));
214 }
215
216 #[test]
217 fn db_path_ends_with_huddle_db() {
218 let path = db_path();
219 assert_eq!(path.file_name().unwrap(), "huddle.db");
220 }
221
222 // huddle 0.7.12 — relay-parsing regression tests. The form below is
223 // verbatim what README.md (line 283) and MANUAL_TESTING.md §14 tell
224 // users to put in config.toml; pre-0.7.12 it parsed to zero relays.
225 #[test]
226 fn parse_relays_documented_multiline_no_header() {
227 let body = "relays = [\n \"/dns4/relay.example.com/tcp/4001/p2p/12D3Koo\",\n]\n";
228 assert_eq!(
229 parse_relays(body),
230 vec!["/dns4/relay.example.com/tcp/4001/p2p/12D3Koo".to_string()]
231 );
232 }
233
234 #[test]
235 fn parse_relays_multiline_with_network_header() {
236 let body = "[network]\nrelays = [\n \"/ip4/1.2.3.4/tcp/4001/p2p/A\",\n \"/ip4/5.6.7.8/tcp/4001/p2p/B\",\n]\n";
237 assert_eq!(
238 parse_relays(body),
239 vec![
240 "/ip4/1.2.3.4/tcp/4001/p2p/A".to_string(),
241 "/ip4/5.6.7.8/tcp/4001/p2p/B".to_string(),
242 ]
243 );
244 }
245
246 #[test]
247 fn parse_relays_single_line_array() {
248 let body = "relays = [\"/ip4/1.2.3.4/tcp/1/p2p/A\", \"/ip4/5.6.7.8/tcp/2/p2p/B\"]";
249 assert_eq!(parse_relays(body).len(), 2);
250 }
251
252 #[test]
253 fn parse_relays_scalar_form() {
254 let body = "relays = \"/ip4/1.2.3.4/tcp/1/p2p/A\"";
255 assert_eq!(
256 parse_relays(body),
257 vec!["/ip4/1.2.3.4/tcp/1/p2p/A".to_string()]
258 );
259 }
260
261 #[test]
262 fn parse_relays_strips_comments_and_blanks() {
263 let body = "# a comment\n\nrelays = [\n \"/ip4/1.2.3.4/tcp/1/p2p/A\", # inline note\n]\n";
264 assert_eq!(
265 parse_relays(body),
266 vec!["/ip4/1.2.3.4/tcp/1/p2p/A".to_string()]
267 );
268 }
269
270 #[test]
271 fn parse_relays_empty_when_absent() {
272 assert!(parse_relays("[network]\nfoo = 1\n").is_empty());
273 assert!(parse_relays("").is_empty());
274 }
275
276 #[test]
277 fn parse_relays_ignores_similar_key() {
278 // `relays_enabled` must not be mistaken for the `relays` array.
279 assert!(parse_relays("relays_enabled = true\n").is_empty());
280 }
281
282 // huddle 0.8 — scalar overrides for the onion relay + SOCKS proxy.
283 #[test]
284 fn parse_scalar_reads_quoted_value() {
285 let body = "server_url = \"ws://abc.onion:80/ws\"\n";
286 assert_eq!(
287 parse_scalar(body, "server_url").as_deref(),
288 Some("ws://abc.onion:80/ws")
289 );
290 }
291
292 #[test]
293 fn parse_scalar_strips_comment_and_header() {
294 let body = "[network]\ntor_socks = \"127.0.0.1:9150\" # tor browser\n";
295 assert_eq!(
296 parse_scalar(body, "tor_socks").as_deref(),
297 Some("127.0.0.1:9150")
298 );
299 }
300
301 #[test]
302 fn parse_scalar_none_when_absent_or_similar_key() {
303 assert!(parse_scalar("foo = 1\n", "server_url").is_none());
304 // prefix collision must not match
305 assert!(parse_scalar("server_url_backup = \"x\"\n", "server_url").is_none());
306 }
307}