1use std::fmt::Display;
4
5use anyhow::Result;
6use camino::Utf8PathBuf;
7use composefs_boot::bootloader::EFI_EXT;
8use composefs_ctl::composefs_boot;
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#[derive(Debug, PartialEq, Eq, Clone)]
24pub(crate) struct MenuentryBody<'a> {
25 pub(crate) insmod: Vec<&'a str>,
27 pub(crate) chainloader: String,
29 pub(crate) search: &'a str,
31 pub(crate) version: u8,
33 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#[derive(Debug, PartialEq, Eq, Clone)]
80pub(crate) struct MenuEntry<'a> {
81 pub(crate) title: String,
83 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 pub(crate) fn boot_artifact_name(&self) -> Result<String> {
131 Ok(self.boot_artifact_info()?.0)
132 }
133
134 pub(crate) fn boot_artifact_info(&self) -> Result<(String, bool)> {
135 let chainloader_path = Utf8PathBuf::from(&self.body.chainloader);
136
137 let file_name = chainloader_path.file_name().ok_or_else(|| {
138 anyhow::anyhow!(
139 "Chainloader path missing file name: {}",
140 &self.body.chainloader
141 )
142 })?;
143
144 let without_suffix = file_name.strip_suffix(EFI_EXT).ok_or_else(|| {
145 anyhow::anyhow!(
146 "EFI file name missing expected suffix '{}': {}",
147 EFI_EXT,
148 file_name
149 )
150 })?;
151
152 match without_suffix.strip_prefix(UKI_NAME_PREFIX) {
154 Some(no_prefix) => Ok((no_prefix.into(), true)),
155 None => Ok((without_suffix.into(), false)),
156 }
157 }
158}
159
160fn take_until_balanced_allow_nested(
162 opening_bracket: char,
163 closing_bracket: char,
164) -> impl Fn(&str) -> IResult<&str, &str> {
165 move |i: &str| {
166 let mut index = 0;
167 let mut bracket_counter = 0;
168
169 while let Some(n) = &i[index..].find(&[opening_bracket, closing_bracket, '\\'][..]) {
170 index += n;
171 let mut characters = i[index..].chars();
172
173 match characters.next().unwrap_or_default() {
174 c if c == '\\' => {
175 index += '\\'.len_utf8();
177 let c = characters.next().unwrap_or_default();
179 index += c.len_utf8();
180 }
181
182 c if c == opening_bracket => {
183 bracket_counter += 1;
184 index += opening_bracket.len_utf8();
185 }
186
187 c if c == closing_bracket => {
188 bracket_counter -= 1;
189 index += closing_bracket.len_utf8();
190 }
191
192 _ => unreachable!(),
194 };
195
196 if bracket_counter == -1 {
198 index -= closing_bracket.len_utf8();
200 return Ok((&i[index..], &i[0..index]));
201 };
202 }
203
204 if bracket_counter == 0 {
205 Ok(("", i))
206 } else {
207 Err(Err::Error(Error::from_error_kind(i, ErrorKind::TakeUntil)))
208 }
209 }
210}
211
212fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> {
214 let (input, _) = tag("menuentry").parse(input)?;
215
216 let (input, _) = multispace1.parse(input)?;
218 let (input, title) = delimited(
220 tag("\""),
221 escaped(none_of("\\\""), '\\', none_of("")),
222 tag("\""),
223 )
224 .parse(input)?;
225
226 let (input, _) = multispace0.parse(input)?;
228
229 let (input, body) = delimited(
231 tag("{"),
232 take_until_balanced_allow_nested('{', '}'),
233 tag("}"),
234 )
235 .parse(input)?;
236
237 let mut map = vec![];
238
239 for line in body.lines() {
240 let line = line.trim();
241
242 if line.is_empty() || line.starts_with('#') {
243 continue;
244 }
245
246 if let Some((key, value)) = line.split_once(' ') {
247 map.push((key, value.trim()));
248 }
249 }
250
251 Ok((
252 input,
253 MenuEntry {
254 title: title.to_string(),
255 body: MenuentryBody::from(map),
256 },
257 ))
258}
259
260fn skip_to_menuentry(input: &str) -> IResult<&str, ()> {
262 let (input, _) = take_until("menuentry")(input)?;
263 Ok((input, ()))
264}
265
266fn parse_all(input: &str) -> IResult<&str, Vec<MenuEntry<'_>>> {
268 let mut remaining = input;
269 let mut entries = Vec::new();
270
271 let Ok((new_input, _)) = skip_to_menuentry(remaining) else {
273 return Ok(("", Default::default()));
274 };
275 remaining = new_input;
276
277 while !remaining.trim().is_empty() {
278 let (new_input, entry) = parse_menuentry(remaining)?;
279 entries.push(entry);
280 remaining = new_input;
281
282 let (ws_input, _) = multispace0(remaining)?;
284 remaining = ws_input;
285
286 if let Ok((next_input, _)) = skip_to_menuentry(remaining) {
287 remaining = next_input;
288 } else if !remaining.trim().is_empty() {
289 break;
291 }
292 }
293
294 Ok((remaining, entries))
295}
296
297pub(crate) fn parse_grub_menuentry_file(contents: &str) -> anyhow::Result<Vec<MenuEntry<'_>>> {
299 let (_, entries) = parse_all(&contents)
300 .map_err(|e| anyhow::anyhow!("Failed to parse GRUB menuentries: {e}"))?;
301 for entry in &entries {
303 if entry.title.is_empty() {
304 anyhow::bail!("Found menuentry with empty title");
305 }
306 }
307
308 Ok(entries)
309}
310
311#[cfg(test)]
312mod test {
313 use super::*;
314
315 #[test]
316 fn test_menuconfig_parser() {
317 let menuentry = r#"
318 if [ -f ${config_directory}/efiuuid.cfg ]; then
319 source ${config_directory}/efiuuid.cfg
320 fi
321
322 # Skip this comment
323
324 menuentry "Fedora 42: (Verity-42)" {
325 insmod fat
326 insmod chain
327 # This should also be skipped
328 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
329 chainloader /EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi
330 }
331
332 menuentry "Fedora 43: (Verity-43)" {
333 insmod fat
334 insmod chain
335 search --no-floppy --set=root --fs-uuid "${EFI_PART_UUID}"
336 chainloader /EFI/Linux/uki.efi
337 extra_field1 this is extra
338 extra_field2 this is also extra
339 }
340 "#;
341
342 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
343
344 let expected = vec![
345 MenuEntry {
346 title: "Fedora 42: (Verity-42)".into(),
347 body: MenuentryBody {
348 insmod: vec!["fat", "chain"],
349 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
350 chainloader: "/EFI/Linux/7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6.efi".into(),
351 version: 0,
352 extra: vec![],
353 },
354 },
355 MenuEntry {
356 title: "Fedora 43: (Verity-43)".into(),
357 body: MenuentryBody {
358 insmod: vec!["fat", "chain"],
359 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
360 chainloader: "/EFI/Linux/uki.efi".into(),
361 version: 0,
362 extra: vec![
363 ("extra_field1", "this is extra"),
364 ("extra_field2", "this is also extra")
365 ]
366 },
367 },
368 ];
369
370 println!("{}", expected[0]);
371
372 assert_eq!(result, expected);
373 }
374
375 #[test]
376 fn test_escaped_quotes_in_title() {
377 let menuentry = r#"
378 menuentry "Title with \"escaped quotes\" inside" {
379 insmod fat
380 chainloader /EFI/Linux/test.efi
381 }
382 "#;
383
384 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
385
386 assert_eq!(result.len(), 1);
387 assert_eq!(result[0].title, "Title with \\\"escaped quotes\\\" inside");
388 assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
389 }
390
391 #[test]
392 fn test_multiple_escaped_quotes() {
393 let menuentry = r#"
394 menuentry "Test \"first\" and \"second\" quotes" {
395 insmod fat
396 chainloader /EFI/Linux/test.efi
397 }
398 "#;
399
400 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
401
402 assert_eq!(result.len(), 1);
403 assert_eq!(
404 result[0].title,
405 "Test \\\"first\\\" and \\\"second\\\" quotes"
406 );
407 }
408
409 #[test]
410 fn test_escaped_backslash_in_title() {
411 let menuentry = r#"
412 menuentry "Path with \\ backslash" {
413 insmod fat
414 chainloader /EFI/Linux/test.efi
415 }
416 "#;
417
418 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
419
420 assert_eq!(result.len(), 1);
421 assert_eq!(result[0].title, "Path with \\\\ backslash");
422 }
423
424 #[test]
425 fn test_minimal_menuentry() {
426 let menuentry = r#"
427 menuentry "Minimal Entry" {
428 # Just a comment
429 }
430 "#;
431
432 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
433
434 assert_eq!(result.len(), 1);
435 assert_eq!(result[0].title, "Minimal Entry");
436 assert_eq!(result[0].body.insmod.len(), 0);
437 assert_eq!(result[0].body.chainloader, "");
438 assert_eq!(result[0].body.search, "");
439 assert_eq!(result[0].body.extra.len(), 0);
440 }
441
442 #[test]
443 fn test_menuentry_with_only_insmod() {
444 let menuentry = r#"
445 menuentry "Insmod Only" {
446 insmod fat
447 insmod chain
448 insmod ext2
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].body.insmod, vec!["fat", "chain", "ext2"]);
456 assert_eq!(result[0].body.chainloader, "");
457 assert_eq!(result[0].body.search, "");
458 }
459
460 #[test]
461 fn test_menuentry_with_set_commands_ignored() {
462 let menuentry = r#"
463 menuentry "With Set Commands" {
464 set timeout=5
465 set root=(hd0,1)
466 insmod fat
467 chainloader /EFI/Linux/test.efi
468 }
469 "#;
470
471 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
472
473 assert_eq!(result.len(), 1);
474 assert_eq!(result[0].body.insmod, vec!["fat"]);
475 assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
476 assert!(!result[0].body.extra.iter().any(|(k, _)| k == &"set"));
478 }
479
480 #[test]
481 fn test_nested_braces_in_body() {
482 let menuentry = r#"
483 menuentry "Nested Braces" {
484 if [ -f ${config_directory}/test.cfg ]; then
485 source ${config_directory}/test.cfg
486 fi
487 insmod fat
488 chainloader /EFI/Linux/test.efi
489 }
490 "#;
491
492 let result = parse_grub_menuentry_file(menuentry).expect("Expected parsed entries");
493
494 assert_eq!(result.len(), 1);
495 assert_eq!(result[0].title, "Nested Braces");
496 assert_eq!(result[0].body.insmod, vec!["fat"]);
497 assert_eq!(result[0].body.chainloader, "/EFI/Linux/test.efi");
498 assert!(result[0].body.extra.iter().any(|(k, _)| k == &"if"));
500 }
501
502 #[test]
503 fn test_empty_file() {
504 let result = parse_grub_menuentry_file("").expect("Should handle empty file");
505 assert_eq!(result.len(), 0);
506 }
507
508 #[test]
509 fn test_file_with_no_menuentries() {
510 let content = r#"
511 # Just comments and other stuff
512 set timeout=10
513 if [ -f /boot/grub/custom.cfg ]; then
514 source /boot/grub/custom.cfg
515 fi
516 "#;
517
518 let result =
519 parse_grub_menuentry_file(content).expect("Should handle file with no menuentries");
520 assert_eq!(result.len(), 0);
521 }
522
523 #[test]
524 fn test_malformed_menuentry_missing_quote() {
525 let menuentry = r#"
526 menuentry "Missing closing quote {
527 insmod fat
528 }
529 "#;
530
531 let result = parse_grub_menuentry_file(menuentry);
532 assert!(result.is_err(), "Should fail on malformed menuentry");
533 }
534
535 #[test]
536 fn test_malformed_menuentry_missing_brace() {
537 let menuentry = r#"
538 menuentry "Missing Brace" {
539 insmod fat
540 chainloader /EFI/Linux/test.efi
541 // Missing closing brace
542 "#;
543
544 let result = parse_grub_menuentry_file(menuentry);
545 assert!(result.is_err(), "Should fail on unbalanced braces");
546 }
547
548 #[test]
549 fn test_multiple_menuentries_with_content_between() {
550 let content = r#"
551 # Some initial config
552 set timeout=10
553
554 menuentry "First Entry" {
555 insmod fat
556 chainloader /EFI/Linux/first.efi
557 }
558
559 # Some comments between entries
560 set default=0
561
562 menuentry "Second Entry" {
563 insmod ext2
564 search --set=root --fs-uuid "some-uuid"
565 chainloader /EFI/Linux/second.efi
566 }
567
568 # Trailing content
569 "#;
570
571 let result = parse_grub_menuentry_file(content)
572 .expect("Should parse multiple entries with content between");
573
574 assert_eq!(result.len(), 2);
575 assert_eq!(result[0].title, "First Entry");
576 assert_eq!(result[0].body.chainloader, "/EFI/Linux/first.efi");
577 assert_eq!(result[1].title, "Second Entry");
578 assert_eq!(result[1].body.chainloader, "/EFI/Linux/second.efi");
579 assert_eq!(result[1].body.search, "--set=root --fs-uuid \"some-uuid\"");
580 }
581
582 #[test]
583 fn test_menuentry_boot_artifact_name_success() {
584 let body = MenuentryBody {
585 insmod: vec!["fat", "chain"],
586 chainloader: "/EFI/Linux/bootc/bootc_composefs-abcd1234.efi".to_string(),
587 search: "--no-floppy --set=root --fs-uuid test",
588 version: 0,
589 extra: vec![],
590 };
591
592 let entry = MenuEntry {
593 title: "Test Entry".to_string(),
594 body,
595 };
596
597 let artifact_name = entry
598 .boot_artifact_name()
599 .expect("Should extract artifact name");
600 assert_eq!(artifact_name, "abcd1234");
601 }
602
603 #[test]
604 fn test_menuentry_boot_artifact_name_missing_prefix() {
605 let body = MenuentryBody {
606 insmod: vec!["fat", "chain"],
607 chainloader: "/EFI/Linux/abcd1234.efi".to_string(),
608 search: "--no-floppy --set=root --fs-uuid test",
609 version: 0,
610 extra: vec![],
611 };
612
613 let entry = MenuEntry {
614 title: "Test Entry".to_string(),
615 body,
616 };
617
618 let artifact_name = entry
619 .boot_artifact_name()
620 .expect("Should extract artifact name");
621 assert_eq!(artifact_name, "abcd1234");
622 }
623
624 #[test]
625 fn test_menuentry_boot_artifact_name_missing_suffix() {
626 let body = MenuentryBody {
627 insmod: vec!["fat", "chain"],
628 chainloader: "/EFI/Linux/bootc/bootc_composefs-abcd1234".to_string(),
629 search: "--no-floppy --set=root --fs-uuid test",
630 version: 0,
631 extra: vec![],
632 };
633
634 let entry = MenuEntry {
635 title: "Test Entry".to_string(),
636 body,
637 };
638
639 let result = entry.boot_artifact_name();
640 assert!(result.is_err());
641 assert!(
642 result
643 .unwrap_err()
644 .to_string()
645 .contains("missing expected suffix")
646 );
647 }
648
649 #[test]
650 fn test_menuentry_boot_artifact_name_empty_chainloader() {
651 let body = MenuentryBody {
652 insmod: vec![],
653 chainloader: "".to_string(),
654 search: "",
655 version: 0,
656 extra: vec![],
657 };
658
659 let entry = MenuEntry {
660 title: "Empty".to_string(),
661 body,
662 };
663
664 let result = entry.boot_artifact_name();
665 assert!(result.is_err());
666 }
667
668 #[test]
677 fn test_menuentry_boot_artifact_name_matches_get_verity() {
678 let digest = "f7415d75017a12a387a39d2281e033a288fc15775108250ef70a01dcadb93346";
679
680 let body = MenuentryBody {
681 insmod: vec!["fat", "chain"],
682 chainloader: format!("/EFI/Linux/bootc/bootc_composefs-{digest}.efi"),
683 search: "--no-floppy --set=root --fs-uuid test",
684 version: 0,
685 extra: vec![],
686 };
687
688 let entry = MenuEntry {
689 title: "Test".to_string(),
690 body,
691 };
692
693 let artifact_name = entry.boot_artifact_name().unwrap();
694 let verity = entry.get_verity().unwrap();
695 assert_eq!(artifact_name, verity);
696 assert_eq!(artifact_name, digest);
697 }
698
699 #[test]
701 fn test_menuentry_boot_artifact_name_full_digest() {
702 let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
703
704 let body = MenuentryBody {
705 insmod: vec!["fat", "chain"],
706 chainloader: format!("/EFI/Linux/bootc/bootc_composefs-{digest}.efi"),
707 search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"",
708 version: 0,
709 extra: vec![],
710 };
711
712 let entry = MenuEntry {
713 title: format!("Fedora Bootc UKI: ({digest})"),
714 body,
715 };
716
717 assert_eq!(entry.boot_artifact_name().unwrap(), digest);
718 }
719
720 #[test]
722 fn test_menuentry_new_boot_artifact_name() {
723 let uki_id = "abc123def456";
724 let entry = MenuEntry::new("Fedora 42", uki_id);
725
726 assert_eq!(entry.boot_artifact_name().unwrap(), uki_id);
727 assert_eq!(entry.get_verity().unwrap(), uki_id);
728 }
729
730 #[test]
732 fn test_menuentry_boot_artifact_name_from_parsed() {
733 let digest = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6";
734 let menuentry = format!(
735 r#"
736 menuentry "Fedora 42: ({digest})" {{
737 insmod fat
738 insmod chain
739 search --no-floppy --set=root --fs-uuid "${{EFI_PART_UUID}}"
740 chainloader /EFI/Linux/bootc/bootc_composefs-{digest}.efi
741 }}
742 "#
743 );
744
745 let result = parse_grub_menuentry_file(&menuentry).unwrap();
746 assert_eq!(result.len(), 1);
747 assert_eq!(result[0].boot_artifact_name().unwrap(), digest);
748 assert_eq!(result[0].get_verity().unwrap(), digest);
749 }
750}