Skip to main content

rust_web_server/proxy_config/
parser.rs

1//! Hand-rolled TOML parser that extracts `[[upstream]]`, `[[route]]`,
2//! `[[tcp_proxy]]`, `[[udp_proxy]]`, `[[ws_proxy]]` sections from
3//! `rws.config.toml` into a flat `SectionMap`.
4//!
5//! The output is a `HashMap<String, Vec<(String, String)>>` where each key is
6//! a section path (e.g. `"route[0].action.proxy"`) and each value is the list
7//! of key-value pairs found in that section.
8
9use std::collections::HashMap;
10
11/// Section path → list of (key, value) pairs.
12pub type SectionMap = HashMap<String, Vec<(String, String)>>;
13
14/// Parse a TOML string into a `SectionMap`.
15///
16/// Supports the subset of TOML used in `rws.config.toml`:
17/// - Array-of-tables `[[X]]`
18/// - Standard tables `[X]`
19/// - Key-value pairs (string, bool, integer, inline tables, arrays)
20pub fn parse(toml: &str) -> SectionMap {
21    let mut map: SectionMap = HashMap::new();
22
23    // Outer array table tracking
24    let mut outer_name: Option<String> = None; // e.g. "route"
25    let mut outer_idx: usize = 0;
26    let mut outer_counters: HashMap<String, usize> = HashMap::new(); // "route" → 2
27    let mut inner_counters: HashMap<String, usize> = HashMap::new(); // "route[0].middleware.rewrite.request" → 1
28
29    // Current section path (where key-value pairs are written)
30    let mut current_section: String = String::new();
31
32    for line in logical_lines(toml) {
33
34        if line.is_empty() {
35            continue;
36        }
37
38        if line.starts_with("[[") && line.ends_with("]]") {
39            // Array-of-tables header
40            let name = line[2..line.len() - 2].trim().to_string();
41
42            // Is this a nested array inside the current outer table?
43            if let Some(ref on) = outer_name.clone() {
44                let prefix = format!("{}.", on);
45                if name.starts_with(&prefix) {
46                    // Nested array: "route[N].middleware.rewrite.request"
47                    let base = format!("{}{}", outer_section_base(on, outer_idx), &name[on.len()..]);
48                    let cnt = inner_counters.entry(base.clone()).or_insert(0);
49                    let section_path = format!("{}[{}]", base, cnt);
50                    *cnt += 1;
51                    current_section = section_path.clone();
52                    map.entry(current_section.clone()).or_default();
53                    continue;
54                }
55            }
56
57            // Top-level array-of-tables
58            let idx = outer_counters.entry(name.clone()).or_insert(0);
59            outer_idx = *idx;
60            *idx += 1;
61            outer_name = Some(name.clone());
62            inner_counters.clear();
63            current_section = outer_section_base(&name, outer_idx);
64            map.entry(current_section.clone()).or_default();
65        } else if line.starts_with('[') && line.ends_with(']') {
66            // Standard table header
67            let name = line[1..line.len() - 1].trim().to_string();
68
69            if let Some(ref on) = outer_name.clone() {
70                let prefix = format!("{}.", on);
71                if name.starts_with(&prefix) {
72                    // Sub-table of current outer: e.g. "route.match" inside "route"
73                    current_section = format!(
74                        "{}{}",
75                        outer_section_base(on, outer_idx),
76                        &name[on.len()..]
77                    );
78                    map.entry(current_section.clone()).or_default();
79                    continue;
80                }
81            }
82
83            // Standalone table (reset outer tracking)
84            outer_name = None;
85            inner_counters.clear();
86            current_section = name.clone();
87            map.entry(current_section.clone()).or_default();
88        } else if let Some(eq) = line.find('=') {
89            // Key = value pair
90            let key = line[..eq].trim().to_string();
91            let raw_val = line[eq + 1..].trim().to_string();
92
93            if raw_val.starts_with('{') {
94                // Inline table: `rate_limit = { max_requests = 5 }` is the
95                // sub-table `[<section>.rate_limit]`, exactly as in TOML. (It
96                // used to become a `rate_limit.max_requests` key in the parent
97                // section, which nothing reads — inline auth, rate limits and
98                // IP filters were silently ignored.)
99                let inner = &raw_val[1..raw_val.rfind('}').unwrap_or(raw_val.len())];
100                let sub_section = if current_section.is_empty() { key.clone() } else { format!("{}.{}", current_section, key) };
101                let entries = map.entry(sub_section).or_default();
102                for part in split_inline_table(inner) {
103                    if let Some(ieq) = part.find('=') {
104                        let ik = part[..ieq].trim().to_string();
105                        let iv = parse_value(part[ieq + 1..].trim());
106                        entries.push((ik, iv));
107                    }
108                }
109            } else {
110                let value = parse_value(&raw_val);
111                map.entry(current_section.clone())
112                    .or_default()
113                    .push((key, value));
114            }
115        }
116    }
117
118    map
119}
120
121/// Build the base path for an outer array entry, e.g. `"route[0]"`.
122fn outer_section_base(name: &str, idx: usize) -> String {
123    format!("{}[{}]", name, idx)
124}
125
126/// Strip an inline TOML comment (first `#` not inside a quoted string).
127/// The lines of `toml` with comments removed and trimmed, a multi-line
128/// array (`key = [` … `]`) joined into one line — the rest of the parser
129/// reads one `key = value` per line. A bracket inside a string doesn't
130/// count; an array left open at the end is returned as it is.
131pub(crate) fn logical_lines(toml: &str) -> Vec<String> {
132    let mut out = Vec::new();
133    let mut pending: Option<(String, i64)> = None;
134    for raw in toml.lines() {
135        let line = strip_comment(raw).trim();
136        if let Some((joined, depth)) = pending.as_mut() {
137            if !line.is_empty() {
138                joined.push(' ');
139                joined.push_str(line);
140                *depth += bracket_depth(line);
141            }
142            if *depth <= 0 {
143                out.push(pending.take().expect("pending").0);
144            }
145            continue;
146        }
147        let opens_array = line.split_once('=').is_some_and(|(key, value)| !key.trim().starts_with('[') && value.trim_start().starts_with('['));
148        let depth = if opens_array { bracket_depth(line) } else { 0 };
149        if depth > 0 {
150            pending = Some((line.to_string(), depth));
151        } else {
152            out.push(line.to_string());
153        }
154    }
155    if let Some((joined, _)) = pending {
156        out.push(joined);
157    }
158    out
159}
160
161/// `[` minus `]` outside quoted strings.
162fn bracket_depth(line: &str) -> i64 {
163    let mut depth = 0;
164    let mut quote: Option<char> = None;
165    let mut escaped = false;
166    for c in line.chars() {
167        match quote {
168            Some(q) => {
169                if escaped {
170                    escaped = false;
171                } else if c == '\\' {
172                    escaped = true;
173                } else if c == q {
174                    quote = None;
175                }
176            }
177            None => match c {
178                '"' | '\'' => quote = Some(c),
179                '[' => depth += 1,
180                ']' => depth -= 1,
181                _ => {}
182            },
183        }
184    }
185    depth
186}
187
188pub(crate) fn strip_comment(line: &str) -> &str {
189    let bytes = line.as_bytes();
190    let mut in_quote = false;
191    let mut quote_char = b'"';
192    let mut i = 0;
193    while i < bytes.len() {
194        let b = bytes[i];
195        if in_quote {
196            if b == quote_char && (i == 0 || bytes[i - 1] != b'\\') {
197                in_quote = false;
198            }
199        } else if b == b'"' || b == b'\'' {
200            in_quote = true;
201            quote_char = b;
202        } else if b == b'#' {
203            return &line[..i];
204        }
205        i += 1;
206    }
207    line
208}
209
210/// Parse a single TOML value into its string representation.
211///
212/// - Quoted strings → bare string (quotes stripped)
213/// - Arrays `["a","b"]` → `"a,b"` (joined with comma)
214/// - Booleans / numbers → as-is
215pub(crate) fn parse_value(raw: &str) -> String {
216    let raw = raw.trim();
217    if raw.starts_with('[') && raw.ends_with(']') {
218        // Array of scalars
219        let inner = &raw[1..raw.len() - 1];
220        let items: Vec<String> = split_array_items(inner)
221            .into_iter()
222            .map(|s| strip_quotes(s.trim()))
223            .collect();
224        return items.join(",");
225    }
226    strip_quotes(raw)
227}
228
229/// Strip leading/trailing `"` or `'` from a value.
230fn strip_quotes(s: &str) -> String {
231    let s = s.trim();
232    // len >= 2: a lone `"` or `'` both starts and ends with a quote.
233    if s.len() >= 2 && ((s.starts_with('"') && s.ends_with('"')) ||
234       (s.starts_with('\'') && s.ends_with('\''))) {
235        s[1..s.len() - 1].to_string()
236    } else {
237        s.to_string()
238    }
239}
240
241/// Split a TOML array body (without brackets) into individual items.
242/// Handles quoted strings that may contain commas.
243fn split_array_items(inner: &str) -> Vec<&str> {
244    let mut items = Vec::new();
245    let mut depth = 0i32;
246    let mut in_quote = false;
247    let mut quote_char = b'"';
248    let mut start = 0;
249    let bytes = inner.as_bytes();
250
251    for (i, &b) in bytes.iter().enumerate() {
252        if in_quote {
253            if b == quote_char {
254                in_quote = false;
255            }
256        } else if b == b'"' || b == b'\'' {
257            in_quote = true;
258            quote_char = b;
259        } else if b == b'[' || b == b'{' {
260            depth += 1;
261        } else if b == b']' || b == b'}' {
262            depth -= 1;
263        } else if b == b',' && depth == 0 {
264            items.push(inner[start..i].trim());
265            start = i + 1;
266        }
267    }
268    let last = inner[start..].trim();
269    if !last.is_empty() {
270        items.push(last);
271    }
272    items
273}
274
275/// Split inline table body `"key = val, key2 = val2"` into parts at top-level commas.
276fn split_inline_table(inner: &str) -> Vec<String> {
277    let mut parts = Vec::new();
278    let mut depth = 0i32;
279    let mut in_quote = false;
280    let mut quote_char = b'"';
281    let mut current = String::new();
282
283    for b in inner.bytes() {
284        if in_quote {
285            current.push(b as char);
286            if b == quote_char {
287                in_quote = false;
288            }
289        } else if b == b'"' || b == b'\'' {
290            in_quote = true;
291            quote_char = b;
292            current.push(b as char);
293        } else if b == b'[' || b == b'{' {
294            depth += 1;
295            current.push(b as char);
296        } else if b == b']' || b == b'}' {
297            depth -= 1;
298            current.push(b as char);
299        } else if b == b',' && depth == 0 {
300            let part = current.trim().to_string();
301            if !part.is_empty() {
302                parts.push(part);
303            }
304            current = String::new();
305        } else {
306            current.push(b as char);
307        }
308    }
309    let part = current.trim().to_string();
310    if !part.is_empty() {
311        parts.push(part);
312    }
313    parts
314}
315
316// ── Helper accessors ───────────────────────────────────────────────────────────
317
318/// Get the first value for `key` in `section`, or `None`.
319pub(crate) fn get(map: &SectionMap, section: &str, key: &str) -> Option<String> {
320    map.get(section)?.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
321}
322
323/// Get the first value for `key` in `section`, or empty string.
324pub(crate) fn get_str(map: &SectionMap, section: &str, key: &str) -> String {
325    get(map, section, key).unwrap_or_default()
326}
327
328/// Get the first value for `key` in `section` as `u64`, or `default`.
329pub(crate) fn get_u64(map: &SectionMap, section: &str, key: &str, default: u64) -> u64 {
330    get(map, section, key)
331        .and_then(|v| v.parse().ok())
332        .unwrap_or(default)
333}
334
335/// Get the first value for `key` in `section` as `u32`, or `default`.
336pub(crate) fn get_u32(map: &SectionMap, section: &str, key: &str, default: u32) -> u32 {
337    get(map, section, key)
338        .and_then(|v| v.parse().ok())
339        .unwrap_or(default)
340}
341
342/// Get the first value for `key` in `section` as a list (split on `,`).
343pub(crate) fn get_array(map: &SectionMap, section: &str, key: &str) -> Vec<String> {
344    match get(map, section, key) {
345        Some(v) if !v.is_empty() => v.split(',').map(|s| s.trim().to_string()).collect(),
346        _ => vec![],
347    }
348}
349
350/// Returns `true` if the section key exists in the map.
351pub(crate) fn section_exists(map: &SectionMap, section: &str) -> bool {
352    map.contains_key(section)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    const SAMPLE: &str = r#"
360[server]
361ip = "0.0.0.0"
362port = 8080
363
364[[upstream]]
365name = "api"
366backends = ["backend1:8080", "backend2:8080"]
367strategy = "round_robin"
368
369[upstream.health_check]
370path = "/health"
371interval_secs = 10
372timeout_ms = 2000
373healthy_threshold = 2
374unhealthy_threshold = 3
375
376[[route]]
377name = "api-route"
378
379[route.match]
380path = "/api/*"
381method = "GET"
382
383[route.action]
384type = "proxy"
385
386[route.action.proxy]
387upstream = "api"
388connect_timeout_ms = 5000
389read_timeout_ms = 30000
390
391[route.middleware]
392
393[route.middleware.rate_limit]
394max_requests = 100
395window_secs = 60
396
397[[route.middleware.rewrite.request]]
398type = "header_set"
399name = "X-Env"
400value = "production"
401
402[[tcp_proxy]]
403name = "db-proxy"
404listen = "0.0.0.0:5432"
405backends = ["db1:5432", "db2:5432"]
406connect_timeout_ms = 3000
407"#;
408
409    #[test]
410    fn parse_server_section() {
411        let map = parse(SAMPLE);
412        assert_eq!(get(&map, "server", "ip").as_deref(), Some("0.0.0.0"));
413        assert_eq!(get_u64(&map, "server", "port", 0), 8080);
414    }
415
416    #[test]
417    fn parse_upstream() {
418        let map = parse(SAMPLE);
419        assert!(section_exists(&map, "upstream[0]"));
420        assert_eq!(get_str(&map, "upstream[0]", "name"), "api");
421        assert_eq!(
422            get_array(&map, "upstream[0]", "backends"),
423            vec!["backend1:8080", "backend2:8080"]
424        );
425    }
426
427    #[test]
428    fn parse_upstream_health_check() {
429        let map = parse(SAMPLE);
430        assert!(section_exists(&map, "upstream[0].health_check"));
431        assert_eq!(get_str(&map, "upstream[0].health_check", "path"), "/health");
432        assert_eq!(get_u64(&map, "upstream[0].health_check", "interval_secs", 0), 10);
433    }
434
435    #[test]
436    fn parse_route() {
437        let map = parse(SAMPLE);
438        assert!(section_exists(&map, "route[0]"));
439        assert_eq!(get_str(&map, "route[0]", "name"), "api-route");
440        assert_eq!(get_str(&map, "route[0].match", "path"), "/api/*");
441        assert_eq!(get_str(&map, "route[0].match", "method"), "GET");
442        assert_eq!(get_str(&map, "route[0].action.proxy", "upstream"), "api");
443        assert_eq!(get_u64(&map, "route[0].action.proxy", "connect_timeout_ms", 0), 5000);
444    }
445
446    #[test]
447    fn parse_route_middleware() {
448        let map = parse(SAMPLE);
449        assert_eq!(get_u32(&map, "route[0].middleware.rate_limit", "max_requests", 0), 100);
450        assert_eq!(get_u64(&map, "route[0].middleware.rate_limit", "window_secs", 0), 60);
451    }
452
453    #[test]
454    fn parse_nested_rewrite_array() {
455        let map = parse(SAMPLE);
456        assert!(section_exists(&map, "route[0].middleware.rewrite.request[0]"));
457        assert_eq!(get_str(&map, "route[0].middleware.rewrite.request[0]", "type"), "header_set");
458        assert_eq!(get_str(&map, "route[0].middleware.rewrite.request[0]", "name"), "X-Env");
459    }
460
461    #[test]
462    fn parse_tcp_proxy() {
463        let map = parse(SAMPLE);
464        assert!(section_exists(&map, "tcp_proxy[0]"));
465        assert_eq!(get_str(&map, "tcp_proxy[0]", "name"), "db-proxy");
466        assert_eq!(get_str(&map, "tcp_proxy[0]", "listen"), "0.0.0.0:5432");
467        assert_eq!(
468            get_array(&map, "tcp_proxy[0]", "backends"),
469            vec!["db1:5432", "db2:5432"]
470        );
471    }
472
473    #[test]
474    fn multi_line_arrays_are_joined() {
475        let toml = "[authz]\ntuples = [\n  \"doc:1#owner@user:a\",  # first\n  \"doc:2#owner@user:[b]\",\n]\nstore = \"memory\"\n";
476        let map = parse(toml);
477        assert_eq!(get_array(&map, "authz", "tuples"), ["doc:1#owner@user:a", "doc:2#owner@user:[b]"]);
478        assert_eq!(get_str(&map, "authz", "store"), "memory");
479        assert_eq!(logical_lines("a = [1,\n2]\n[t]\nb = []"), ["a = [1, 2]", "[t]", "b = []"]);
480        assert_eq!(logical_lines("a = [1,\n2"), ["a = [1, 2"], "an unclosed array ends with the file");
481    }
482
483    #[test]
484    fn strip_comments_test() {
485        assert_eq!(strip_comment("key = \"value\" # a comment"), "key = \"value\" ");
486        assert_eq!(strip_comment("# full comment"), "");
487        assert_eq!(strip_comment("url = \"http://example.com#fragment\""), "url = \"http://example.com#fragment\"");
488    }
489
490    #[test]
491    fn parse_value_array() {
492        assert_eq!(parse_value(r#"["a", "b", "c"]"#), "a,b,c");
493    }
494
495    #[test]
496    fn parse_value_string() {
497        assert_eq!(parse_value(r#""hello""#), "hello");
498    }
499}