bootc_lib/parsers/
grub_menuconfig.rs

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