bootc_lib/parsers/
grub_menuconfig.rs

1//! Parser for GRUB menuentry configuration files using nom combinators.
2
3#![allow(dead_code)]
4
5use std::fmt::Display;
6
7use anyhow::Result;
8use camino::Utf8PathBuf;
9use composefs_boot::bootloader::EFI_EXT;
10use nom::{
11    Err, IResult, Parser,
12    bytes::complete::{escaped, tag, take_until},
13    character::complete::{multispace0, multispace1, none_of},
14    error::{Error, ErrorKind, ParseError},
15    sequence::delimited,
16};
17
18/// Body content of a GRUB menuentry containing parsed commands.
19#[derive(Debug, PartialEq, Eq)]
20pub(crate) struct MenuentryBody<'a> {
21    /// Kernel modules to load
22    pub(crate) insmod: Vec<&'a str>,
23    /// Chainloader path (optional)
24    pub(crate) chainloader: String,
25    /// Search command (optional)
26    pub(crate) search: &'a str,
27    /// The version
28    pub(crate) version: u8,
29    /// Additional commands
30    pub(crate) extra: Vec<(&'a str, &'a str)>,
31}
32
33impl<'a> Display for MenuentryBody<'a> {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        for insmod in &self.insmod {
36            writeln!(f, "insmod {}", insmod)?;
37        }
38
39        writeln!(f, "search {}", self.search)?;
40        writeln!(f, "chainloader {}", self.chainloader)?;
41
42        for (k, v) in &self.extra {
43            writeln!(f, "{k} {v}")?;
44        }
45
46        Ok(())
47    }
48}
49
50impl<'a> From<Vec<(&'a str, &'a str)>> for MenuentryBody<'a> {
51    fn from(vec: Vec<(&'a str, &'a str)>) -> Self {
52        let mut entry = Self {
53            insmod: vec![],
54            chainloader: "".into(),
55            search: "",
56            version: 0,
57            extra: vec![],
58        };
59
60        for (key, value) in vec {
61            match key {
62                "insmod" => entry.insmod.push(value),
63                "chainloader" => entry.chainloader = value.into(),
64                "search" => entry.search = value,
65                "set" => {}
66                _ => entry.extra.push((key, value)),
67            }
68        }
69
70        entry
71    }
72}
73
74/// A complete GRUB menuentry with title and body commands.
75#[derive(Debug, PartialEq, Eq)]
76pub(crate) struct MenuEntry<'a> {
77    /// Display title (supports escaped quotes)
78    pub(crate) title: String,
79    /// Commands within the menuentry block
80    pub(crate) body: MenuentryBody<'a>,
81}
82
83impl<'a> Display for MenuEntry<'a> {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        writeln!(f, "menuentry \"{}\" {{", self.title)?;
86        write!(f, "{}", self.body)?;
87        writeln!(f, "}}")
88    }
89}
90
91impl<'a> MenuEntry<'a> {
92    #[allow(dead_code)]
93    pub(crate) fn new(boot_label: &str, uki_id: &str) -> Self {
94        Self {
95            title: format!("{boot_label}: ({uki_id})"),
96            body: MenuentryBody {
97                insmod: vec!["fat", "chain"],
98                chainloader: format!("/EFI/Linux/{uki_id}.efi"),
99                search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
100                version: 0,
101                extra: vec![],
102            },
103        }
104    }
105
106    pub(crate) fn get_verity(&self) -> Result<String> {
107        let to_path = Utf8PathBuf::from(self.body.chainloader.clone());
108
109        Ok(to_path
110            .components()
111            .last()
112            .ok_or(anyhow::anyhow!("Empty efi field"))?
113            .to_string()
114            .strip_suffix(EFI_EXT)
115            .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))?
116            .to_string())
117    }
118}
119
120/// Parser that takes content until balanced brackets, handling nested brackets and escapes.
121fn take_until_balanced_allow_nested(
122    opening_bracket: char,
123    closing_bracket: char,
124) -> impl Fn(&str) -> IResult<&str, &str> {
125    move |i: &str| {
126        let mut index = 0;
127        let mut bracket_counter = 0;
128
129        while let Some(n) = &i[index..].find(&[opening_bracket, closing_bracket, '\\'][..]) {
130            index += n;
131            let mut characters = i[index..].chars();
132
133            match characters.next().unwrap_or_default() {
134                c if c == '\\' => {
135                    // Skip '\'
136                    index += '\\'.len_utf8();
137                    // Skip char following '\'
138                    let c = characters.next().unwrap_or_default();
139                    index += c.len_utf8();
140                }
141
142                c if c == opening_bracket => {
143                    bracket_counter += 1;
144                    index += opening_bracket.len_utf8();
145                }
146
147                c if c == closing_bracket => {
148                    bracket_counter -= 1;
149                    index += closing_bracket.len_utf8();
150                }
151
152                // Should not happen
153                _ => unreachable!(),
154            };
155
156            // We found the unmatched closing bracket.
157            if bracket_counter == -1 {
158                // Don't consume it as we'll "tag" it afterwards
159                index -= closing_bracket.len_utf8();
160                return Ok((&i[index..], &i[0..index]));
161            };
162        }
163
164        if bracket_counter == 0 {
165            Ok(("", i))
166        } else {
167            Err(Err::Error(Error::from_error_kind(i, ErrorKind::TakeUntil)))
168        }
169    }
170}
171
172/// Parses a single menuentry with title and body commands.
173fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> {
174    let (input, _) = tag("menuentry").parse(input)?;
175
176    // Require at least one space after "menuentry"
177    let (input, _) = multispace1.parse(input)?;
178    // Eat up the title, handling escaped quotes
179    let (input, title) = delimited(
180        tag("\""),
181        escaped(none_of("\\\""), '\\', none_of("")),
182        tag("\""),
183    )
184    .parse(input)?;
185
186    // Skip any whitespace after title
187    let (input, _) = multispace0.parse(input)?;
188
189    // Eat up everything inside { .. }
190    let (input, body) = delimited(
191        tag("{"),
192        take_until_balanced_allow_nested('{', '}'),
193        tag("}"),
194    )
195    .parse(input)?;
196
197    let mut map = vec![];
198
199    for line in body.lines() {
200        let line = line.trim();
201
202        if line.is_empty() || line.starts_with('#') {
203            continue;
204        }
205
206        if let Some((key, value)) = line.split_once(' ') {
207            map.push((key, value.trim()));
208        }
209    }
210
211    Ok((
212        input,
213        MenuEntry {
214            title: title.to_string(),
215            body: MenuentryBody::from(map),
216        },
217    ))
218}
219
220/// Skips content until finding "menuentry" keyword or end of input.
221fn skip_to_menuentry(input: &str) -> IResult<&str, ()> {
222    let (input, _) = take_until("menuentry")(input)?;
223    Ok((input, ()))
224}
225
226/// Parses all menuentries from a GRUB configuration file.
227fn parse_all(input: &str) -> IResult<&str, Vec<MenuEntry<'_>>> {
228    let mut remaining = input;
229    let mut entries = Vec::new();
230
231    // Skip any content before the first menuentry
232    let Ok((new_input, _)) = skip_to_menuentry(remaining) else {
233        return Ok(("", Default::default()));
234    };
235    remaining = new_input;
236
237    while !remaining.trim().is_empty() {
238        let (new_input, entry) = parse_menuentry(remaining)?;
239        entries.push(entry);
240        remaining = new_input;
241
242        // Skip whitespace and try to find next menuentry
243        let (ws_input, _) = multispace0(remaining)?;
244        remaining = ws_input;
245
246        if let Ok((next_input, _)) = skip_to_menuentry(remaining) {
247            remaining = next_input;
248        } else if !remaining.trim().is_empty() {
249            // No more menuentries found, but content remains
250            break;
251        }
252    }
253
254    Ok((remaining, entries))
255}
256
257/// Main entry point for parsing GRUB menuentry files.
258pub(crate) fn parse_grub_menuentry_file(contents: &str) -> anyhow::Result<Vec<MenuEntry<'_>>> {
259    let (_, entries) = parse_all(&contents)
260        .map_err(|e| anyhow::anyhow!("Failed to parse GRUB menuentries: {e}"))?;
261    // Validate that entries have reasonable structure
262    for entry in &entries {
263        if entry.title.is_empty() {
264            anyhow::bail!("Found menuentry with empty title");
265        }
266    }
267
268    Ok(entries)
269}
270
271#[cfg(test)]
272mod test {
273    use super::*;
274
275    #[test]
276    fn test_menuconfig_parser() {
277        let menuentry = r#"
278            if [ -f ${config_directory}/efiuuid.cfg ]; then
279                    source ${config_directory}/efiuuid.cfg
280            fi
281
282            # Skip this comment
283
284            menuentry "Fedora 42: (Verity-42)" {
285                insmod fat
286                insmod chain
287                # This should also be skipped
288                search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
289                chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
290            }
291
292            menuentry "Fedora 43: (Verity-43)" {
293                insmod fat
294                insmod chain
295                search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
296                chainloader /EFI/Linux/uki.efi
297                extra_field1 this is extra
298                extra_field2 this is also extra
299            }
300        "#;
301
302        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
303
304        let expected = vec![
305            MenuEntry {
306                title: "Fedora 42: (Verity-42)".into(),
307                body: MenuentryBody {
308                    insmod: vec!["fat", "chain"],
309                    search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
310                    chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
311                    version: 0,
312                    extra: vec![],
313                },
314            },
315            MenuEntry {
316                title: "Fedora 43: (Verity-43)".into(),
317                body: MenuentryBody {
318                    insmod: vec!["fat", "chain"],
319                    search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
320                    chainloader: "/EFI/Linux/uki.efi".into(),
321                    version: 0,
322                    extra: vec![
323                        ("extra_field1", "this is extra"), 
324                        ("extra_field2", "this is also extra")
325                    ]
326                },
327            },
328        ];
329
330        println!("{}", expected[0]);
331
332        assert_eq!(result, expected);
333    }
334
335    #[test]
336    fn test_escaped_quotes_in_title() {
337        let menuentry = r#"
338            menuentry "Title with \"escaped quotes\" inside" {
339                insmod fat
340                chainloader /EFI/Linux/test.efi
341            }
342        "#;
343
344        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
345
346        assert_eq!(result.len(), 1);
347        assert_eq!(result[0].title, "Title with \\\"escaped quotes\\\" inside");
348        assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
349    }
350
351    #[test]
352    fn test_multiple_escaped_quotes() {
353        let menuentry = r#"
354            menuentry "Test \"first\" and \"second\" quotes" {
355                insmod fat
356                chainloader /EFI/Linux/test.efi
357            }
358        "#;
359
360        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
361
362        assert_eq!(result.len(), 1);
363        assert_eq!(
364            result[0].title,
365            "Test \\\"first\\\" and \\\"second\\\" quotes"
366        );
367    }
368
369    #[test]
370    fn test_escaped_backslash_in_title() {
371        let menuentry = r#"
372            menuentry "Path with \\ backslash" {
373                insmod fat
374                chainloader /EFI/Linux/test.efi
375            }
376        "#;
377
378        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
379
380        assert_eq!(result.len(), 1);
381        assert_eq!(result[0].title, "Path with \\\\ backslash");
382    }
383
384    #[test]
385    fn test_minimal_menuentry() {
386        let menuentry = r#"
387            menuentry "Minimal Entry" {
388                # Just a comment
389            }
390        "#;
391
392        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
393
394        assert_eq!(result.len(), 1);
395        assert_eq!(result[0].title, "Minimal Entry");
396        assert_eq!(result[0].body.insmod.len(), 0);
397        assert_eq!(result[0].body.chainloader, "");
398        assert_eq!(result[0].body.search, "");
399        assert_eq!(result[0].body.extra.len(), 0);
400    }
401
402    #[test]
403    fn test_menuentry_with_only_insmod() {
404        let menuentry = r#"
405            menuentry "Insmod Only" {
406                insmod fat
407                insmod chain
408                insmod ext2
409            }
410        "#;
411
412        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
413
414        assert_eq!(result.len(), 1);
415        assert_eq!(result[0].body.insmod, vec!["fat", "chain", "ext2"]);
416        assert_eq!(result[0].body.chainloader, "");
417        assert_eq!(result[0].body.search, "");
418    }
419
420    #[test]
421    fn test_menuentry_with_set_commands_ignored() {
422        let menuentry = r#"
423            menuentry "With Set Commands" {
424                set timeout=5
425                set root=(hd0,1)
426                insmod fat
427                chainloader /EFI/Linux/test.efi
428            }
429        "#;
430
431        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
432
433        assert_eq!(result.len(), 1);
434        assert_eq!(result[0].body.insmod, vec!["fat"]);
435        assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
436        // set commands should be ignored
437        assert!(!result[0].body.extra.iter().any(|(k, _)| k == &"set"));
438    }
439
440    #[test]
441    fn test_nested_braces_in_body() {
442        let menuentry = r#"
443            menuentry "Nested Braces" {
444                if [ -f ${config_directory}/test.cfg ]; then
445                    source ${config_directory}/test.cfg
446                fi
447                insmod fat
448                chainloader /EFI/Linux/test.efi
449            }
450        "#;
451
452        let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
453
454        assert_eq!(result.len(), 1);
455        assert_eq!(result[0].title, "Nested Braces");
456        assert_eq!(result[0].body.insmod, vec!["fat"]);
457        assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
458        // The if/fi block should be captured as extra commands
459        assert!(result[0].body.extra.iter().any(|(k, _)| k == &"if"));
460    }
461
462    #[test]
463    fn test_empty_file() {
464        let result = parse_grub_menuentry_file("").expect("Should handle empty file");
465        assert_eq!(result.len(), 0);
466    }
467
468    #[test]
469    fn test_file_with_no_menuentries() {
470        let content = r#"
471            # Just comments and other stuff
472            set timeout=10
473            if [ -f /boot/grub/custom.cfg ]; then
474                source /boot/grub/custom.cfg
475            fi
476        "#;
477
478        let result =
479            parse_grub_menuentry_file(content).expect("Should handle file with no menuentries");
480        assert_eq!(result.len(), 0);
481    }
482
483    #[test]
484    fn test_malformed_menuentry_missing_quote() {
485        let menuentry = r#"
486            menuentry "Missing closing quote {
487                insmod fat
488            }
489        "#;
490
491        let result = parse_grub_menuentry_file(menuentry);
492        assert!(result.is_err(), "Should fail on malformed menuentry");
493    }
494
495    #[test]
496    fn test_malformed_menuentry_missing_brace() {
497        let menuentry = r#"
498            menuentry "Missing Brace" {
499                insmod fat
500                chainloader /EFI/Linux/test.efi
501            // Missing closing brace
502        "#;
503
504        let result = parse_grub_menuentry_file(menuentry);
505        assert!(result.is_err(), "Should fail on unbalanced braces");
506    }
507
508    #[test]
509    fn test_multiple_menuentries_with_content_between() {
510        let content = r#"
511            # Some initial config
512            set timeout=10
513            
514            menuentry "First Entry" {
515                insmod fat
516                chainloader /EFI/Linux/first.efi
517            }
518            
519            # Some comments between entries
520            set default=0
521            
522            menuentry "Second Entry" {
523                insmod ext2
524                search --set=root --fs-uuid "some-uuid"
525                chainloader /EFI/Linux/second.efi
526            }
527            
528            # Trailing content
529        "#;
530
531        let result = parse_grub_menuentry_file(content)
532            .expect("Should parse multiple entries with content between");
533
534        assert_eq!(result.len(), 2);
535        assert_eq!(result[0].title, "First Entry");
536        assert_eq!(result[0].body.chainloader, "/EFI/Linux/first.efi");
537        assert_eq!(result[1].title, "Second Entry");
538        assert_eq!(result[1].body.chainloader, "/EFI/Linux/second.efi");
539        assert_eq!(result[1].body.search, "--set=root --fs-uuid \"some-uuid\"");
540    }
541}