1use anyhow::{bail, Context, Result};
16use nix::mount;
17use regex::{Captures, Regex};
18use serde::Deserialize;
19use std::collections::HashMap;
20use std::fs::{self, File, OpenOptions, Permissions};
21use std::io::{self, BufReader, Seek, SeekFrom, Write};
22use std::num::NonZeroU32;
23use std::os::unix::fs::{FileTypeExt, PermissionsExt};
24use std::path::{Path, PathBuf};
25
26use crate::blockdev::*;
27use crate::cmdline::*;
28use crate::download::*;
29use crate::io::*;
30#[cfg(target_arch = "s390x")]
31use crate::s390x;
32use crate::source::*;
33
34const GRUB_CFG_CONSOLE_SETTINGS_RE: &str = r"(?P<prefix>\n# CONSOLE-SETTINGS-START\n)(?P<commands>([^\n]*\n)*)(?P<suffix># CONSOLE-SETTINGS-END\n)";
37
38pub fn install(config: InstallConfig) -> Result<()> {
39 let config = config.expand_config_files()?;
41
42 let device = config
44 .dest_device
45 .as_deref()
46 .context("destination device must be specified")?;
47
48 let mut ignition = if let Some(file) = &config.ignition_file {
50 Some(
51 OpenOptions::new()
52 .read(true)
53 .open(file)
54 .with_context(|| format!("opening source Ignition config {file}"))?,
55 )
56 } else if let Some(url) = &config.ignition_url {
57 if url.scheme() == "http" {
58 if config.ignition_hash.is_none() && !config.insecure_ignition {
59 bail!("refusing to fetch Ignition config over HTTP without --ignition-hash or --insecure-ignition");
60 }
61 } else if url.scheme() != "https" {
62 bail!("unknown protocol for URL '{}'", url);
63 }
64 Some(
65 download_to_tempfile(url, config.fetch_retries)
66 .with_context(|| format!("downloading source Ignition config {url}"))?,
67 )
68 } else {
69 None
70 };
71 if let Some(mut file) = ignition.as_mut() {
72 let reader = BufReader::with_capacity(BUFFER_SIZE, &mut file);
78 serde_json::from_reader::<_, serde_json::Value>(reader)
79 .context("parsing specified Ignition config")?;
80 file.rewind().context("rewinding Ignition config file")?;
81 }
82
83 let network_config = if config.copy_network {
88 Some(config.network_dir.as_str())
89 } else {
90 None
91 };
92
93 let save_partitions = parse_partition_filters(
95 &config
96 .save_partlabel
97 .iter()
98 .map(|s| s.as_str())
99 .collect::<Vec<&str>>(),
100 &config
101 .save_partindex
102 .iter()
103 .map(|s| s.as_str())
104 .collect::<Vec<&str>>(),
105 )?;
106
107 #[allow(clippy::match_bool, clippy::match_single_binding)]
112 let sector_size = match is_dasd(device, None)
113 .with_context(|| format!("checking whether {device} is an IBM DASD disk"))?
114 {
115 #[cfg(target_arch = "s390x")]
116 true => s390x::dasd_try_get_sector_size(device).transpose(),
117 _ => None,
118 };
119 let sector_size = sector_size
120 .unwrap_or_else(|| get_sector_size_for_path(Path::new(device)))
121 .with_context(|| format!("getting sector size of {device}"))?
122 .get();
123
124 #[cfg(target_arch = "s390x")]
128 {
129 if is_dasd(device, None)? {
130 if !save_partitions.is_empty() {
131 bail!("saving DASD partitions is not supported");
135 }
136 s390x::prepare_dasd(device)?;
137 }
138 }
139
140 let location: Box<dyn ImageLocation> = if let Some(image_file) = &config.image_file {
143 Box::new(FileLocation::new(image_file))
144 } else if let Some(image_url) = &config.image_url {
145 Box::new(UrlLocation::new(image_url, config.fetch_retries))
146 } else if config.offline {
147 match OsmetLocation::new(config.architecture.as_str(), sector_size)? {
148 Some(osmet) => Box::new(osmet),
149 None => bail!("cannot perform offline install; metadata missing"),
150 }
151 } else {
152 let maybe_osmet = match config.stream {
156 Some(_) => None,
157 None => OsmetLocation::new(config.architecture.as_str(), sector_size)?,
158 };
159
160 if let Some(osmet) = maybe_osmet {
161 Box::new(osmet)
162 } else {
163 let format = match sector_size {
164 4096 => "4k.raw.xz",
165 512 => "raw.xz",
166 n => {
167 eprintln!(
170 "Found non-standard sector size {n} for {device}, assuming 512b-compatible"
171 );
172 "raw.xz"
173 }
174 };
175 Box::new(StreamLocation::new(
176 config.stream.as_deref().unwrap_or("stable"),
177 config.architecture.as_str(),
178 "metal",
179 format,
180 config.stream_base_url.as_ref(),
181 config.fetch_retries,
182 )?)
183 }
184 };
185 eprintln!("{location}");
187 let mut sources = location.sources()?;
189 let mut source = sources.pop().context("no artifacts found")?;
190 if !sources.is_empty() {
191 bail!("found multiple artifacts");
192 }
193 if source.signature.is_none() && location.require_signature() {
194 if config.insecure {
195 eprintln!("Signature not found; skipping verification as requested");
196 } else {
197 bail!("--insecure not specified and signature not found");
198 }
199 }
200
201 let mut dest = OpenOptions::new()
203 .read(true)
204 .write(true)
205 .open(device)
206 .with_context(|| format!("opening {device}"))?;
207 if !dest
208 .metadata()
209 .with_context(|| format!("getting metadata for {device}"))?
210 .file_type()
211 .is_block_device()
212 {
213 bail!("{} is not a block device", device);
214 }
215 ensure_exclusive_access(device)
216 .with_context(|| format!("checking for exclusive access to {device}"))?;
217
218 let saved = SavedPartitions::new_from_disk(&mut dest, &save_partitions)
220 .with_context(|| format!("saving partitions from {device}"))?;
221
222 let mut table = Disk::new(device)?
226 .get_partition_table()
227 .with_context(|| format!("getting partition table for {device}"))?;
228
229 dest.rewind().with_context(|| format!("seeking {device}"))?;
233 if let Err(err) = write_disk(
234 &config,
235 &mut source,
236 &mut dest,
237 &mut *table,
238 &saved,
239 ignition,
240 network_config,
241 ) {
242 eprintln!("\nError: {err:?}\n");
245
246 if config.preserve_on_error {
248 eprintln!("Preserving partition table as requested");
249 if saved.is_saved() {
250 stash_saved_partitions(&mut dest, &saved)?;
257 }
258 } else {
259 reset_partition_table(&config, &mut dest, &mut *table, &saved)?;
260 }
261
262 bail!("install failed");
264 }
265
266 match get_filesystems_with_label("boot", true) {
270 Ok(pts) => {
271 if pts.len() > 1 {
272 let rootdev = fs::canonicalize(device)
273 .unwrap_or_else(|_| PathBuf::from(device))
274 .to_string_lossy()
275 .to_string();
276 let pts = pts
277 .iter()
278 .filter(|pt| !pt.contains(&rootdev))
279 .collect::<Vec<_>>();
280 eprintln!("\nNote: detected other devices with a filesystem labeled `boot`:");
281 for pt in pts {
282 eprintln!(" - {pt}");
283 }
284 eprintln!("The installed OS may not work correctly if there are multiple boot filesystems.
285Before rebooting, investigate whether these filesystems are needed and consider
286wiping them with `wipefs -a`.\n"
287 );
288 }
289 }
290 Err(e) => eprintln!("checking filesystems labeled 'boot': {e:?}"),
291 }
292
293 eprintln!("Install complete.");
294 Ok(())
295}
296
297fn parse_partition_filters(labels: &[&str], indexes: &[&str]) -> Result<Vec<PartitionFilter>> {
298 use PartitionFilter::*;
299 let mut filters: Vec<PartitionFilter> = Vec::new();
300
301 for glob in labels {
303 let filter = Label(
304 glob::Pattern::new(glob)
305 .with_context(|| format!("couldn't parse label glob '{glob}'"))?,
306 );
307 filters.push(filter);
308 }
309
310 let parse_index = |i: &str| -> Result<Option<NonZeroU32>> {
312 match i {
313 "" => Ok(None), _ => Ok(Some(
315 NonZeroU32::new(
316 i.parse()
317 .with_context(|| format!("couldn't parse partition index '{i}'"))?,
318 )
319 .context("partition index cannot be zero")?,
320 )),
321 }
322 };
323 for range in indexes {
324 let parts: Vec<&str> = range.split('-').collect();
325 let filter = match parts.len() {
326 1 => Index(parse_index(parts[0])?, parse_index(parts[0])?),
327 2 => Index(parse_index(parts[0])?, parse_index(parts[1])?),
328 _ => bail!("couldn't parse partition index range '{}'", range),
329 };
330 match filter {
331 Index(None, None) => bail!(
332 "both ends of partition index range '{}' cannot be open",
333 range
334 ),
335 Index(Some(x), Some(y)) if x > y => bail!(
336 "start of partition index range '{}' cannot be greater than end",
337 range
338 ),
339 _ => filters.push(filter),
340 };
341 }
342 Ok(filters)
343}
344
345fn ensure_exclusive_access(device: &str) -> Result<()> {
346 let mut parts = Disk::new(device)?.get_busy_partitions()?;
347 if parts.is_empty() {
348 return Ok(());
349 }
350 parts.sort_unstable_by_key(|p| p.path.to_string());
351 eprintln!("Partitions in use on {device}:");
352 for part in parts {
353 if let Some(mountpoint) = part.mountpoint.as_ref() {
354 eprintln!(" {} mounted on {}", part.path, mountpoint);
355 }
356 if part.swap {
357 eprintln!(" {} is swap device", part.path);
358 }
359 for holder in part.get_holders()? {
360 eprintln!(" {} in use by {}", part.path, holder);
361 }
362 }
363 bail!("found busy partitions");
364}
365
366fn write_disk(
370 config: &InstallConfig,
371 source: &mut ImageSource,
372 dest: &mut File,
373 table: &mut dyn PartTable,
374 saved: &SavedPartitions,
375 ignition: Option<File>,
376 network_config: Option<&str>,
377) -> Result<()> {
378 let device = config.dest_device.as_deref().expect("device missing");
379
380 let sector_size = get_sector_size(dest)?;
382
383 #[allow(clippy::match_bool, clippy::match_single_binding)]
385 let image_copy = match is_dasd(device, Some(dest))? {
386 #[cfg(target_arch = "s390x")]
387 true => s390x::image_copy_s390x,
388 _ => image_copy_default,
389 };
390 write_image(
391 source,
392 dest,
393 Path::new(device),
394 image_copy,
395 true,
396 Some(saved),
397 Some(sector_size),
398 VerifyKeys::Production,
399 )?;
400 table.reread()?;
401
402 if ignition.is_some()
404 || config.firstboot_args.is_some()
405 || !config.append_karg.is_empty()
406 || !config.delete_karg.is_empty()
407 || config.platform.is_some()
408 || !config.console.is_empty()
409 || network_config.is_some()
410 || cfg!(target_arch = "s390x")
411 {
412 let mount = Disk::new(device)?.mount_partition_by_label("boot", mount::MsFlags::empty())?;
413 if let Some(ignition) = ignition.as_ref() {
414 write_ignition(mount.mountpoint(), &config.ignition_hash, ignition)
415 .context("writing Ignition configuration")?;
416 }
417 if let Some(platform) = config.platform.as_ref() {
418 write_platform(mount.mountpoint(), platform).context("writing platform ID")?;
419 }
420 if config.platform.is_some() || !config.console.is_empty() {
421 write_console(
422 mount.mountpoint(),
423 config.platform.as_deref(),
424 &config.console,
425 )
426 .context("configuring console")?;
427 }
428 if let Some(firstboot_args) = config.firstboot_args.as_ref() {
429 write_firstboot_kargs(mount.mountpoint(), firstboot_args)
430 .context("writing firstboot kargs")?;
431 }
432 if !config.append_karg.is_empty() || !config.delete_karg.is_empty() {
433 eprintln!("Modifying kernel arguments");
434
435 Console::maybe_warn_on_kargs(&config.append_karg, "--append-karg", "--console");
436 visit_bls_entry_options(mount.mountpoint(), |orig_options: &str| {
437 KargsEditor::new()
438 .append(config.append_karg.as_slice())
439 .delete(config.delete_karg.as_slice())
440 .maybe_apply_to(orig_options)
441 })
442 .context("deleting and appending kargs")?;
443 }
444 if let Some(network_config) = network_config.as_ref() {
445 copy_network_config(mount.mountpoint(), network_config)?;
446 }
447 #[cfg(target_arch = "s390x")]
448 {
449 s390x::zipl(
450 mount.mountpoint(),
451 None,
452 None,
453 s390x::ZiplSecexMode::Disable,
454 None,
455 )?;
456 s390x::chreipl(device)?;
457 if config.secure_ipl {
458 s390x::set_loaddev(device)?;
459 }
460 }
461 }
462
463 dest.sync_all().context("syncing data to disk")?;
465
466 Ok(())
467}
468
469fn write_ignition(
471 mountpoint: &Path,
472 digest_in: &Option<IgnitionHash>,
473 mut config_in: &File,
474) -> Result<()> {
475 eprintln!("Writing Ignition config");
476
477 if let Some(digest) = &digest_in {
479 digest
480 .validate(&mut config_in)
481 .context("failed to validate Ignition configuration digest")?;
482 config_in
483 .rewind()
484 .context("rewinding Ignition configuration file")?;
485 };
486
487 let mut config_dest = mountpoint.to_path_buf();
489 config_dest.push("ignition");
490 if !config_dest.is_dir() {
491 fs::create_dir_all(&config_dest).with_context(|| {
492 format!(
493 "creating Ignition config directory {}",
494 config_dest.display()
495 )
496 })?;
497 fs::set_permissions(&config_dest, Permissions::from_mode(0o700)).with_context(|| {
499 format!(
500 "setting file mode for Ignition directory {}",
501 config_dest.display()
502 )
503 })?;
504 }
505
506 config_dest.push("config.ign");
508 let mut config_out = OpenOptions::new()
509 .write(true)
510 .create_new(true)
511 .open(&config_dest)
512 .with_context(|| {
513 format!(
514 "opening destination Ignition config {}",
515 config_dest.display()
516 )
517 })?;
518 fs::set_permissions(&config_dest, Permissions::from_mode(0o600)).with_context(|| {
520 format!(
521 "setting file mode for destination Ignition config {}",
522 config_dest.display()
523 )
524 })?;
525 io::copy(&mut config_in, &mut config_out).context("writing Ignition config")?;
526
527 Ok(())
528}
529
530fn write_firstboot_kargs(mountpoint: &Path, args: &str) -> Result<()> {
532 eprintln!("Writing first-boot kernel arguments");
533
534 let mut config_dest = mountpoint.to_path_buf();
536 config_dest.push("ignition.firstboot");
537 let mut config_out = OpenOptions::new()
540 .append(true)
541 .open(&config_dest)
542 .with_context(|| format!("opening first-boot file {}", config_dest.display()))?;
543 let contents = format!("set ignition_network_kcmdline=\"{args}\"\n");
544 config_out
545 .write_all(contents.as_bytes())
546 .context("writing first-boot kernel arguments")?;
547
548 Ok(())
549}
550
551#[derive(Clone, Default, Deserialize)]
552struct PlatformSpec {
553 #[serde(default)]
554 grub_commands: Vec<String>,
555 #[serde(default)]
556 kernel_arguments: Vec<String>,
557}
558
559fn write_platform(mountpoint: &Path, platform: &str) -> Result<()> {
561 if platform == "metal" {
564 return Ok(());
565 }
566 eprintln!("Setting platform to {platform}");
567
568 visit_bls_entry_options(mountpoint, |orig_options: &str| {
571 let new_options = KargsEditor::new()
572 .replace(&[format!("ignition.platform.id=metal={platform}")])
573 .apply_to(orig_options)
574 .context("setting platform ID argument")?;
575 if orig_options == new_options {
576 bail!("couldn't locate platform ID");
577 }
578 Ok(Some(new_options))
579 })?;
580 Ok(())
581}
582
583fn write_console(mountpoint: &Path, platform: Option<&str>, consoles: &[Console]) -> Result<()> {
585 let platforms = match fs::read_to_string(mountpoint.join("coreos/platforms.json")) {
587 Ok(json) => serde_json::from_str::<HashMap<String, PlatformSpec>>(&json)
588 .context("parsing platform table")?,
589 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
591 Err(e) => return Err(e).context("reading platform table"),
592 };
593
594 let mut kargs = Vec::new();
595 let mut grub_commands = Vec::new();
596 if !consoles.is_empty() {
597 let mut grub_terminals = Vec::new();
600 for console in consoles {
601 kargs.push(console.karg());
602 if let Some(cmd) = console.grub_command() {
603 grub_commands.push(cmd);
604 }
605 grub_terminals.push(console.grub_terminal());
606 }
607 grub_terminals.sort_unstable();
608 grub_terminals.dedup();
609 for direction in ["input", "output"] {
610 grub_commands.push(format!("terminal_{direction} {}", grub_terminals.join(" ")));
611 }
612 } else if let Some(platform) = platform {
613 if platform == "metal" {
615 return Ok(());
618 }
619 let spec = platforms.get(platform).cloned().unwrap_or_default();
620 kargs.extend(spec.kernel_arguments);
621 grub_commands.extend(spec.grub_commands);
622 } else {
623 unreachable!();
625 }
626
627 let metal_spec = platforms.get("metal").cloned().unwrap_or_default();
629 visit_bls_entry_options(mountpoint, |orig_options: &str| {
630 KargsEditor::new()
631 .append(&kargs)
632 .delete(&metal_spec.kernel_arguments)
633 .maybe_apply_to(orig_options)
634 .context("setting platform kernel arguments")
635 })?;
636
637 if grub_commands != metal_spec.grub_commands {
639 let mut name = "grub2/console.cfg";
641 let mut path = mountpoint.join(name);
642 if !path.exists() {
643 name = "grub2/grub.cfg";
644 path = mountpoint.join(name);
645 }
646 let grub_cfg = fs::read_to_string(&path).with_context(|| format!("reading {name}"))?;
647 let new_grub_cfg = update_grub_cfg_console_settings(&grub_cfg, &grub_commands)
648 .with_context(|| format!("updating {name}"))?;
649 fs::write(&path, new_grub_cfg).with_context(|| format!("writing {name}"))?;
650 }
651 Ok(())
652}
653
654fn update_grub_cfg_console_settings(grub_cfg: &str, commands: &[String]) -> Result<String> {
657 let mut new_commands = commands.join("\n");
658 if !new_commands.is_empty() {
659 new_commands.push('\n');
660 }
661 let re = Regex::new(GRUB_CFG_CONSOLE_SETTINGS_RE).unwrap();
662 if !re.is_match(grub_cfg) {
663 bail!("missing substitution marker in grub.cfg");
664 }
665 Ok(re
666 .replace(grub_cfg, |caps: &Captures| {
667 format!(
668 "{}{}{}",
669 caps.name("prefix").expect("didn't match prefix").as_str(),
670 new_commands,
671 caps.name("suffix").expect("didn't match suffix").as_str()
672 )
673 })
674 .into_owned())
675}
676
677fn copy_network_config(mountpoint: &Path, net_config_src: &str) -> Result<()> {
679 eprintln!("Copying networking configuration from {net_config_src}");
680
681 let net_config_dest = mountpoint.join("coreos-firstboot-network");
683
684 fs::create_dir_all(&net_config_dest).with_context(|| {
686 format!(
687 "creating destination networking config directory {}",
688 net_config_dest.display()
689 )
690 })?;
691
692 for entry in fs::read_dir(net_config_src)
694 .with_context(|| format!("reading directory {net_config_src}"))?
695 {
696 let entry = entry.with_context(|| format!("reading directory {net_config_src}"))?;
697 let srcpath = entry.path();
698 let destpath = net_config_dest.join(entry.file_name());
699 if srcpath.is_file() {
700 eprintln!("Copying {} to installed system", srcpath.display());
701 fs::copy(&srcpath, destpath).context("Copying networking config")?;
702 }
703 }
704
705 Ok(())
706}
707
708fn reset_partition_table(
711 config: &InstallConfig,
712 dest: &mut File,
713 table: &mut dyn PartTable,
714 saved: &SavedPartitions,
715) -> Result<()> {
716 eprintln!("Resetting partition table");
717 let device = config.dest_device.as_deref().expect("device missing");
718
719 if is_dasd(device, Some(dest))? {
720 dest.rewind().context("seeking to start of disk")?;
724 let zeroes = [0u8; 1024 * 1024];
725 dest.write_all(&zeroes)
726 .context("clearing primary partition table")?;
727 } else {
728 saved
730 .overwrite(dest)
731 .context("restoring saved partitions")?;
732 }
733
734 dest.sync_all().context("syncing partition table to disk")?;
736 table.reread()?;
737
738 Ok(())
739}
740
741fn stash_saved_partitions(disk: &mut File, saved: &SavedPartitions) -> Result<()> {
744 let mut stash = tempfile::Builder::new()
745 .prefix("coreos-installer-partitions.")
746 .tempfile()
747 .context("creating partition stash file")?;
748 let path = stash.path().to_owned();
749 eprintln!("Storing saved partition entries to {}", path.display());
750 let len = disk.seek(SeekFrom::End(0)).context("seeking disk")?;
751 stash
752 .as_file()
753 .set_len(len)
754 .with_context(|| format!("extending partition stash file {}", path.display()))?;
755 saved
756 .overwrite(stash.as_file_mut())
757 .with_context(|| format!("stashing saved partitions to {}", path.display()))?;
758 stash
759 .keep()
760 .with_context(|| format!("retaining saved partition stash in {}", path.display()))?;
761 Ok(())
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn test_parse_partition_filters() {
770 use PartitionFilter::*;
771
772 let g = |v| Label(glob::Pattern::new(v).unwrap());
773 let i = |v| Some(NonZeroU32::new(v).unwrap());
774
775 assert_eq!(
776 parse_partition_filters(&["foo", "z*b?", ""], &["1", "7-7", "2-4", "-3", "4-"])
777 .unwrap(),
778 vec![
779 g("foo"),
780 g("z*b?"),
781 g(""),
782 Index(i(1), i(1)),
783 Index(i(7), i(7)),
784 Index(i(2), i(4)),
785 Index(None, i(3)),
786 Index(i(4), None)
787 ]
788 );
789
790 let bad_globs = vec![("***", "couldn't parse label glob '***'")];
791 for (glob, err) in bad_globs {
792 assert_eq!(
793 &parse_partition_filters(&["f", glob, "z*"], &["7-", "34"])
794 .unwrap_err()
795 .to_string(),
796 err
797 );
798 }
799
800 let bad_ranges = vec![
801 ("", "both ends of partition index range '' cannot be open"),
802 ("-", "both ends of partition index range '-' cannot be open"),
803 ("--", "couldn't parse partition index range '--'"),
804 ("0", "partition index cannot be zero"),
805 ("-2-3", "couldn't parse partition index range '-2-3'"),
806 ("23q", "couldn't parse partition index '23q'"),
807 ("23-45.7", "couldn't parse partition index '45.7'"),
808 ("0x7", "couldn't parse partition index '0x7'"),
809 (
810 "9-7",
811 "start of partition index range '9-7' cannot be greater than end",
812 ),
813 ];
814 for (range, err) in bad_ranges {
815 assert_eq!(
816 &parse_partition_filters(&["f", "z*"], &["7-", range, "34"])
817 .unwrap_err()
818 .to_string(),
819 err
820 );
821 }
822 }
823
824 #[test]
825 fn test_update_grub_cfg() {
826 let base_cfgs = vec![
827 "a\nb\n# CONSOLE-SETTINGS-START\n# CONSOLE-SETTINGS-END\nc\nd",
829 "a\nb\n# CONSOLE-SETTINGS-START\nas df\n# CONSOLE-SETTINGS-END\nc\nd",
831 "a\nb\n# CONSOLE-SETTINGS-START\nas df\nghjkl\n# CONSOLE-SETTINGS-END\nc\nd",
833 ];
834 for cfg in base_cfgs {
835 assert_eq!(
837 update_grub_cfg_console_settings(cfg, &[]).unwrap(),
838 "a\nb\n# CONSOLE-SETTINGS-START\n# CONSOLE-SETTINGS-END\nc\nd"
839 );
840 assert_eq!(
842 update_grub_cfg_console_settings(cfg, &["first".into()]).unwrap(),
843 "a\nb\n# CONSOLE-SETTINGS-START\nfirst\n# CONSOLE-SETTINGS-END\nc\nd"
844 );
845 assert_eq!(
847 update_grub_cfg_console_settings(cfg, &["first".into(), "sec ond".into(), "third".into()]).unwrap(),
848 "a\nb\n# CONSOLE-SETTINGS-START\nfirst\nsec ond\nthird\n# CONSOLE-SETTINGS-END\nc\nd"
849 );
850 }
851
852 update_grub_cfg_console_settings("a\nb\nc\nd", &[]).unwrap_err();
854 }
855}