Skip to main content

strat9_kernel/boot/
toml.rs

1//! Minimal TOML subset parser for kernel boot configuration.
2//!
3//! Zero-copy design: keys, section names and unescaped string values borrow
4//! directly from the source buffer (`Cow::Borrowed`); only values requiring
5//! escape-sequence expansion allocate. Entries are stored in one flat,
6//! sorted `Vec` (binary-search lookups) instead of nested maps : the config
7//! is parsed once at boot and read-only afterwards.
8//!
9//! # Lifetime contract
10//!
11//! [`TomlConfig<'a>`] borrows the input buffer: the caller must keep the
12//! TOML text alive for as long as the config is used. This is natural for
13//! boot configs read from persistent regions (initfs / mapped file).
14//!
15//! # Supported subset
16//!
17//! - `[section]` headers (no nested tables, non-empty names)
18//! - `key = value` inside a section (keys before any header are rejected)
19//! - Value types: integers (decimal + `0x` hex), booleans, basic strings
20//! - `#` comments, full-line or trailing, respecting quoted strings
21//! - Basic-string escapes: `\n \t \r \" \\`
22//!
23//! Not supported by design: floats, arrays, inline tables, multi-line
24//! strings, dotted keys, duplicate keys (rejected at parse time).
25use alloc::{borrow::Cow, vec::Vec};
26
27/// A single configuration value.
28#[derive(Debug, Clone)]
29pub enum ConfigValue<'a> {
30    Integer(i64),
31    Boolean(bool),
32    /// `Cow::Borrowed` when the raw text needed no unescaping (common case),
33    /// `Cow::Owned` only when `\n`-style escapes had to be expanded.
34    String(Cow<'a, str>),
35}
36
37/// Parsed TOML configuration: flat `(section, key, value)` triples kept
38/// sorted for O(log n) binary-search lookups with minimal memory overhead.
39#[derive(Debug, Clone)]
40pub struct TomlConfig<'a> {
41    entries: Vec<(&'a str, &'a str, ConfigValue<'a>)>,
42}
43
44impl<'a> TomlConfig<'a> {
45    fn lookup(&self, section: &str, key: &str) -> Option<&ConfigValue<'a>> {
46        let idx = self
47            .entries
48            .binary_search_by(|(s, k, _)| (*s, *k).cmp(&(section, key)))
49            .ok()?;
50        Some(&self.entries[idx].2)
51    }
52
53    pub fn get_int(&self, section: &str, key: &str) -> Option<i64> {
54        match self.lookup(section, key)? {
55            ConfigValue::Integer(v) => Some(*v),
56            _ => None,
57        }
58    }
59
60    pub fn get_bool(&self, section: &str, key: &str) -> Option<bool> {
61        match self.lookup(section, key)? {
62            ConfigValue::Boolean(v) => Some(*v),
63            _ => None,
64        }
65    }
66
67    pub fn get_str(&self, section: &str, key: &str) -> Option<&str> {
68        match self.lookup(section, key)? {
69            // Return tied to &self: the Cow may be Borrowed ('a) or Owned,
70            // so the public view is just a shared str borrow of the config.
71            ConfigValue::String(v) => Some(v.as_ref()),
72            _ => None,
73        }
74    }
75
76    pub fn len(&self) -> usize {
77        self.entries.len()
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.entries.is_empty()
82    }
83}
84
85/// Parse a TOML-subset document. The returned config borrows `data`.
86///
87/// Errors are `&'static str` diagnostics suitable for early-boot logging.
88pub fn parse_toml<'a>(data: &'a [u8]) -> Result<TomlConfig<'a>, &'static str> {
89    let text = core::str::from_utf8(data).map_err(|_| "invalid UTF-8 in TOML")?;
90
91    // Pre-size once: one entry max per line keeps reallocations away.
92    let approx_lines = text.bytes().filter(|&b| b == b'\n').count();
93    let mut entries: Vec<(&'a str, &'a str, ConfigValue<'a>)> = Vec::with_capacity(approx_lines);
94
95    let mut sec: &'a str = "";
96    let mut seen_section = false;
97
98    for raw_line in text.lines() {
99        let line = strip_comment(raw_line).trim();
100        if line.is_empty() || line.starts_with('#') {
101            continue;
102        }
103
104        if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
105            if name.contains('.') {
106                return Err("nested tables not supported");
107            }
108            if name.trim().is_empty() {
109                return Err("empty section name");
110            }
111            sec = name.trim();
112            seen_section = true;
113            continue;
114        }
115
116        if !seen_section {
117            return Err("key-value pair before any [section] header");
118        }
119
120        // Spec: only the FIRST '=' separates key and value; everything
121        // after it belongs to the value (so `path = "a=b"` works).
122        let (key, val) = line.split_once('=').ok_or("invalid TOML syntax")?;
123        let key = key.trim();
124        let val = val.trim();
125
126        let v = if val == "true" {
127            ConfigValue::Boolean(true)
128        } else if val == "false" {
129            ConfigValue::Boolean(false)
130        } else if let Some(hex) = val.strip_prefix("0x").or_else(|| val.strip_prefix("0X")) {
131            ConfigValue::Integer(i64::from_str_radix(hex, 16).map_err(|_| "invalid hex")?)
132        } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
133            let inner = &val[1..val.len() - 1];
134            ConfigValue::String(unescape_cow(inner))
135        } else {
136            ConfigValue::Integer(val.parse().map_err(|_| {
137                "unsupported value type (expected bool, int, hex, or quoted string)"
138            })?)
139        };
140
141        entries.push((sec, key, v));
142    }
143
144    // Sort once; equal (section, key) pairs become adjacent, so duplicate
145    // detection is a single adjacent scan instead of a map lookup per row.
146    entries.sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
147    for w in entries.windows(2) {
148        if w[0].0 == w[1].0 && w[0].1 == w[1].1 {
149            return Err("duplicate key in TOML section");
150        }
151    }
152
153    Ok(TomlConfig { entries })
154}
155
156/// Strip a trailing `#` comment, ignoring `#` inside quoted strings.
157fn strip_comment(line: &str) -> &str {
158    let mut in_str = false;
159    for (i, c) in line.char_indices() {
160        match c {
161            '"' => in_str = !in_str,
162            '#' if !in_str => return &line[..i],
163            _ => {}
164        }
165    }
166    line
167}
168
169/// Expand basic-string escapes, borrowing when there is nothing to expand.
170fn unescape_cow(s: &str) -> Cow<'_, str> {
171    if !s.contains('\\') {
172        return Cow::Borrowed(s);
173    }
174    let mut out = alloc::string::String::with_capacity(s.len());
175    let mut chars = s.chars();
176    while let Some(c) = chars.next() {
177        if c == '\\' {
178            match chars.next() {
179                Some('n') => out.push('\n'),
180                Some('t') => out.push('\t'),
181                Some('r') => out.push('\r'),
182                Some('"') => out.push('"'),
183                Some('\\') => out.push('\\'),
184                // Unknown escapes: keep literally (lenient on purpose).
185                Some(other) => out.push(other),
186                None => {}
187            }
188        } else {
189            out.push(c);
190        }
191    }
192    Cow::Owned(out)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_parse_simple() {
201        let toml = b"
202# Comment
203[buddy]
204compaction_threshold = 35
205";
206        let config = parse_toml(toml).unwrap();
207        assert_eq!(config.get_int("buddy", "compaction_threshold"), Some(35));
208    }
209
210    #[test]
211    fn test_parse_multiple_sections() {
212        let toml = b"
213[section1]
214key1 = 100
215key2 = true
216
217[section2]
218key3 = \"hello\"
219";
220        let config = parse_toml(toml).unwrap();
221        assert_eq!(config.get_int("section1", "key1"), Some(100));
222        assert_eq!(config.get_bool("section1", "key2"), Some(true));
223        assert_eq!(config.get_str("section2", "key3"), Some("hello"));
224    }
225
226    #[test]
227    fn test_parse_hex() {
228        let toml = b"
229[test]
230value = 0xFF
231";
232        let config = parse_toml(toml).unwrap();
233        assert_eq!(config.get_int("test", "value"), Some(255));
234    }
235
236    #[test]
237    fn test_trailing_comment_outside_string() {
238        let toml = b"
239[s]
240threshold = 35  # annotation
241name = \"a#b\"  # hash inside string stays
242";
243        let config = parse_toml(toml).unwrap();
244        assert_eq!(config.get_int("s", "threshold"), Some(35));
245        assert_eq!(config.get_str("s", "name"), Some("a#b"));
246    }
247
248    #[test]
249    fn test_escape_sequences() {
250        let toml = b"
251[s]
252path = \"C:\\\\boot\\\\kernel\"
253msg = \"line\\\\nnext\"
254plain = \"no\\\\escapes\"
255";
256        let config = parse_toml(toml).unwrap();
257        assert_eq!(config.get_str("s", "path"), Some("C:\\boot\\kernel"));
258        assert_eq!(config.get_str("s", "msg"), Some("line\nnext"));
259        assert_eq!(config.get_str("s", "plain"), Some("no\\escapes"));
260    }
261
262    #[test]
263    fn test_value_containing_equals() {
264        let toml = b"
265[s]
266pair = \"key=value\"
267";
268        let config = parse_toml(toml).unwrap();
269        assert_eq!(config.get_str("s", "pair"), Some("key=value"));
270    }
271
272    #[test]
273    fn test_reject_key_before_section() {
274        let toml = b"orphan = 1\n[s]\nx = 1\n";
275        assert_eq!(
276            parse_toml(toml).err(),
277            Some("key-value pair before any [section] header")
278        );
279    }
280
281    #[test]
282    fn test_reject_duplicate_key() {
283        let toml = b"
284[s]
285k = 1
286k = 2
287";
288        assert_eq!(
289            parse_toml(toml).err(),
290            Some("duplicate key in TOML section")
291        );
292    }
293
294    #[test]
295    fn test_reject_empty_section_name() {
296        let toml = b"
297[]
298x = 1
299";
300        assert_eq!(parse_toml(toml).err(), Some("empty section name"));
301    }
302
303    #[test]
304    fn test_reject_nested_table() {
305        let toml = b"
306[a.b]
307x = 1
308";
309        assert_eq!(parse_toml(toml).err(), Some("nested tables not supported"));
310    }
311
312    #[test]
313    fn test_float_error_message() {
314        let toml = b"
315[s]
316ratio = 0.5
317";
318        assert_eq!(
319            parse_toml(toml).err(),
320            Some("unsupported value type (expected bool, int, hex, or quoted string)")
321        );
322    }
323
324    #[test]
325    fn test_malformed_quote_is_not_a_string() {
326        // Unterminated quote falls through to the int parser and must be
327        // rejected with the explicit message rather than silently truncated.
328        let toml = b"
329[s]
330val = \"not-closed
331";
332        assert!(parse_toml(toml)
333            .err()
334            .unwrap()
335            .starts_with("unsupported value type"));
336    }
337}