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