1use 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#[derive(Debug, PartialEq, Eq)]
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)]
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 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 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
156fn 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 index += '\\'.len_utf8();
173 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 _ => unreachable!(),
190 };
191
192 if bracket_counter == -1 {
194 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
208fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> {
210 let (input, _) = tag("menuentry").parse(input)?;
211
212 let (input, _) = multispace1.parse(input)?;
214 let (input, title) = delimited(
216 tag("\""),
217 escaped(none_of("\\\""), '\\', none_of("")),
218 tag("\""),
219 )
220 .parse(input)?;
221
222 let (input, _) = multispace0.parse(input)?;
224
225 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
256fn skip_to_menuentry(input: &str) -> IResult<&str, ()> {
258 let (input, _) = take_until("menuentry")(input)?;
259 Ok((input, ()))
260}
261
262fn parse_all(input: &str) -> IResult<&str, Vec<MenuEntry<'_>>> {
264 let mut remaining = input;
265 let mut entries = Vec::new();
266
267 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 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 break;
287 }
288 }
289
290 Ok((remaining, entries))
291}
292
293pub(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 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 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 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}