1#![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#[derive(Debug, PartialEq, Eq)]
20pub(crate) struct MenuentryBody<'a> {
21 pub(crate) insmod: Vec<&'a str>,
23 pub(crate) chainloader: String,
25 pub(crate) search: &'a str,
27 pub(crate) version: u8,
29 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#[derive(Debug, PartialEq, Eq)]
76pub(crate) struct MenuEntry<'a> {
77 pub(crate) title: String,
79 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
120fn 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 index += '\\'.len_utf8();
137 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 _ => unreachable!(),
154 };
155
156 if bracket_counter == -1 {
158 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
172fn parse_menuentry(input: &str) -> IResult<&str, MenuEntry<'_>> {
174 let (input, _) = tag("menuentry").parse(input)?;
175
176 let (input, _) = multispace1.parse(input)?;
178 let (input, title) = delimited(
180 tag("\""),
181 escaped(none_of("\\\""), '\\', none_of("")),
182 tag("\""),
183 )
184 .parse(input)?;
185
186 let (input, _) = multispace0.parse(input)?;
188
189 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
220fn skip_to_menuentry(input: &str) -> IResult<&str, ()> {
222 let (input, _) = take_until("menuentry")(input)?;
223 Ok((input, ()))
224}
225
226fn parse_all(input: &str) -> IResult<&str, Vec<MenuEntry<'_>>> {
228 let mut remaining = input;
229 let mut entries = Vec::new();
230
231 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 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 break;
251 }
252 }
253
254 Ok((remaining, entries))
255}
256
257pub(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 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 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 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}