├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── list.rs └── timeout.rs ├── rust-toolchain └── src ├── entry.rs ├── lib.rs └── loader.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "systemd-boot-conf" 3 | version = "0.2.2" 4 | authors = ["Michael Aaron Murphy "] 5 | repository = "https://github.com/pop-os/systemd-boot-conf" 6 | description = "Managing the systemd-boot loader configuration" 7 | license = "MIT" 8 | readme = "README.md" 9 | keywords = ["linux", "systemd", "systemd-boot"] 10 | categories = ["os::unix-apis"] 11 | edition = "2018" 12 | 13 | [dependencies] 14 | itertools = "0.10" 15 | once_cell = "1.3" 16 | thiserror = "1.0" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 System76 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # systemd-boot-manager 2 | 3 | Rust crate for convenient handling of the systemd-boot loader configuration, as well as the loader 4 | entries that it maintains. This may be used to modify the loader configuration, create new loader 5 | entries, or modify existing ones. 6 | 7 | ## Examples 8 | 9 | Examples may be found in the [examples directory](./examples). 10 | 11 | ``` 12 | # cargo build --examples 13 | # target/debug/examples/list 14 | loader: 15 | default: Some("Pop_OS-current") 16 | timeout: None 17 | entry: Pop_OS-current 18 | title: Pop!_OS 19 | linux: /EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/vmlinuz.efi 20 | initrd: Some("/EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/initrd.img") 21 | options: ["root=UUID=ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3", "ro", "i8042.nomux", "i8042.reset", "loglevel=0", "quiet", "splash", "systemd.show_status=false", "elevator=bfq"] 22 | entry: Pop_OS-oldkern 23 | title: Pop!_OS 24 | linux: /EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/vmlinuz-previous.efi 25 | initrd: Some("/EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/initrd.img-previous") 26 | options: ["root=UUID=ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3", "ro", "i8042.nomux", "i8042.reset", "loglevel=0", "quiet", "splash", "systemd.show_status=false", "elevator=bfq"] 27 | entry: Recovery-0BE5-B90E 28 | title: Pop!_OS Recovery 29 | linux: /EFI/Recovery-0BE5-B90E/vmlinuz.efi 30 | initrd: Some("/EFI/Recovery-0BE5-B90E/initrd.gz") 31 | options: ["quiet", "loglevel=0", "systemd.show_status=false", "splash", "boot=casper", "hostname=recovery", "userfullname=Recovery", "username=recovery", "live-media-path=/casper-0BE5-B90E", "noprompt"] 32 | entry: Pop_OS-xanmod 33 | title: Pop!_OS 34 | linux: /EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/vmlinuz-xanmod.efi 35 | initrd: Some("/EFI/Pop_OS-ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3/initrd-xanmod.img") 36 | options: ["root=UUID=ed646eba-b8a3-4c79-8f93-5ee1a25c6ec3", "ro", "i8042.nomux", "i8042.reset", "loglevel=0", "quiet", "splash", "systemd.show_status=false", "elevator=bfq"] 37 | ``` 38 | -------------------------------------------------------------------------------- /examples/list.rs: -------------------------------------------------------------------------------- 1 | extern crate systemd_boot_conf; 2 | 3 | use std::process::exit; 4 | use systemd_boot_conf::SystemdBootConf; 5 | 6 | pub fn main() { 7 | let manager = match SystemdBootConf::new("/boot/efi") { 8 | Ok(manager) => manager, 9 | Err(why) => { 10 | eprintln!("failed to get systemd-boot info: {}", why); 11 | exit(1); 12 | } 13 | }; 14 | 15 | println!( 16 | "loader:\n default: {:?}\n timeout: {:?}", 17 | manager.loader_conf.default, manager.loader_conf.timeout 18 | ); 19 | 20 | for entry in manager.entries { 21 | println!( 22 | " id: {}\n title: {}\n linux: {}\n initrd: {:?}\n options: {:?}", 23 | entry.id, entry.title, entry.linux, entry.initrd, entry.options 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/timeout.rs: -------------------------------------------------------------------------------- 1 | extern crate systemd_boot_conf; 2 | 3 | use std::process::exit; 4 | use systemd_boot_conf::SystemdBootConf; 5 | 6 | pub fn main() { 7 | let mut manager = match SystemdBootConf::new("/boot/efi") { 8 | Ok(manager) => manager, 9 | Err(why) => { 10 | eprintln!("failed to get systemd-boot info: {}", why); 11 | exit(1); 12 | } 13 | }; 14 | 15 | manager.loader_conf.timeout = Some(10); 16 | if let Err(why) = manager.overwrite_loader_conf() { 17 | eprintln!("failed to overwrite systemd-boot loader: {}", why); 18 | exit(1); 19 | } 20 | 21 | println!("successfully overwrote loader conf"); 22 | if let Err(why) = manager.load_conf() { 23 | eprintln!("failed to reload systemd-boot loader conf: {}", why); 24 | exit(1); 25 | } 26 | 27 | println!( 28 | "loader:\n default: {:?}\n timeout: {:?}", 29 | manager.loader_conf.default, manager.loader_conf.timeout 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.41.0 2 | -------------------------------------------------------------------------------- /src/entry.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use std::fs::File; 3 | use std::io::{self, BufRead, BufReader}; 4 | use std::path::Path; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum EntryError { 8 | #[error("error reading line in entry file")] 9 | Line(#[source] io::Error), 10 | #[error("title field is missing")] 11 | MisisngTitle, 12 | #[error("entry is not a file")] 13 | NotAFile, 14 | #[error("entry does not have a file name")] 15 | NoFilename, 16 | #[error("initrd was defined without a value")] 17 | NoValueForInitrd, 18 | #[error("linux was defined without a value")] 19 | NoValueForLinux, 20 | #[error("error opening entry file")] 21 | Open(#[source] io::Error), 22 | #[error("entry has a file name that is not UTF-8")] 23 | Utf8Filename, 24 | } 25 | 26 | #[derive(Debug, Default, Clone)] 27 | pub struct Entry { 28 | pub id: Box, 29 | pub initrd: Option>, 30 | pub linux: Box, 31 | pub options: Vec>, 32 | pub title: Box, 33 | } 34 | 35 | impl Entry { 36 | pub fn from_path>(path: P) -> Result { 37 | let path = path.as_ref(); 38 | 39 | if !path.is_file() { 40 | return Err(EntryError::NotAFile); 41 | } 42 | 43 | let file_name = match path.file_stem() { 44 | Some(file_name) => match file_name.to_str() { 45 | Some(file_name) => file_name.to_owned(), 46 | None => return Err(EntryError::Utf8Filename), 47 | }, 48 | None => return Err(EntryError::NoFilename), 49 | }; 50 | 51 | let file = File::open(path).map_err(EntryError::Open)?; 52 | 53 | let mut entry = Entry::default(); 54 | entry.id = file_name.into(); 55 | 56 | for line in BufReader::new(file).lines() { 57 | let line = line.map_err(EntryError::Line)?; 58 | let mut fields = line.split_whitespace(); 59 | match fields.next() { 60 | Some("title") => entry.title = fields.join(" ").into(), 61 | Some("linux") => match fields.next() { 62 | Some(value) => entry.linux = value.into(), 63 | None => return Err(EntryError::NoValueForLinux), 64 | }, 65 | Some("initrd") => match fields.next() { 66 | Some(value) => entry.initrd = Some(value.into()), 67 | None => return Err(EntryError::NoValueForInitrd), 68 | }, 69 | Some("options") => entry.options = fields.map(Box::from).collect(), 70 | _ => (), 71 | } 72 | } 73 | 74 | if entry.title.is_empty() { 75 | return Err(EntryError::MisisngTitle); 76 | } 77 | 78 | Ok(entry) 79 | } 80 | 81 | /// Determines if this boot entry is the current boot entry 82 | /// 83 | /// # Implementation 84 | /// 85 | /// This is determined by a matching the entry's initd and options to `/proc/cmdline`. 86 | pub fn is_current(&self) -> bool { 87 | let initrd = self 88 | .initrd 89 | .as_ref() 90 | .map(|x| ["initrd=", &x.replace('/', "\\")].concat()); 91 | 92 | let initrd = initrd.as_ref().map(String::as_str); 93 | let options = self.options.iter().map(Box::as_ref); 94 | 95 | let expected_cmdline = initrd.iter().cloned().chain(options); 96 | 97 | crate::kernel_cmdline() 98 | .iter() 99 | .cloned() 100 | .zip(expected_cmdline) 101 | .all(|(a, b)| a == b) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Rust crate for managing the systemd-boot loader configuration. 2 | 3 | #[macro_use] 4 | extern crate thiserror; 5 | 6 | pub mod entry; 7 | pub mod loader; 8 | 9 | use self::entry::*; 10 | use self::loader::*; 11 | 12 | use once_cell::sync::OnceCell; 13 | 14 | use std::fs; 15 | use std::fs::File; 16 | use std::io::prelude::*; 17 | use std::io::{self, BufWriter}; 18 | use std::path::{Path, PathBuf}; 19 | 20 | #[derive(Debug, Error)] 21 | pub enum Error { 22 | #[error("error reading loader enrties directory")] 23 | EntriesDir(#[source] io::Error), 24 | #[error("error parsing entry at {:?}", path)] 25 | Entry { path: PathBuf, source: EntryError }, 26 | #[error("error writing entry file")] 27 | EntryWrite(#[source] io::Error), 28 | #[error("error reading entry in loader entries directory")] 29 | FileEntry(#[source] io::Error), 30 | #[error("error parsing loader conf at {:?}", path)] 31 | Loader { path: PathBuf, source: LoaderError }, 32 | #[error("error writing loader file")] 33 | LoaderWrite(#[source] io::Error), 34 | #[error("entry not found in data structure")] 35 | NotFound, 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | pub struct SystemdBootConf { 40 | pub efi_mount: Box, 41 | pub entries_path: Box, 42 | pub loader_path: Box, 43 | pub entries: Vec, 44 | pub loader_conf: LoaderConf, 45 | } 46 | 47 | impl SystemdBootConf { 48 | pub fn new>(efi_mount: P) -> Result { 49 | let efi_mount = efi_mount.into(); 50 | let entries_path = efi_mount.join("loader/entries").into(); 51 | let loader_path = efi_mount.join("loader/loader.conf").into(); 52 | 53 | let mut manager = Self { 54 | efi_mount: efi_mount.into(), 55 | entries_path, 56 | loader_path, 57 | entries: Vec::default(), 58 | loader_conf: LoaderConf::default(), 59 | }; 60 | 61 | manager.load_conf()?; 62 | manager.load_entries()?; 63 | 64 | Ok(manager) 65 | } 66 | 67 | /// Find the boot entry which matches the current boot 68 | /// 69 | /// # Implementation 70 | /// 71 | /// The current boot option is determined by a matching the entry's initd and options 72 | /// to `/proc/cmdline`. 73 | pub fn current_entry(&self) -> Option<&Entry> { 74 | self.entries.iter().find(|e| e.is_current()) 75 | } 76 | 77 | /// Validate that the default entry exists. 78 | pub fn default_entry_exists(&self) -> DefaultState { 79 | match self.loader_conf.default { 80 | Some(ref default) => { 81 | if self.entry_exists(default) { 82 | DefaultState::Exists 83 | } else { 84 | DefaultState::DoesNotExist 85 | } 86 | } 87 | None => DefaultState::NotDefined, 88 | } 89 | } 90 | 91 | /// Validates that an entry exists with this name. 92 | pub fn entry_exists(&self, entry: &str) -> bool { 93 | self.entries.iter().any(|e| e.id.as_ref() == entry) 94 | } 95 | 96 | /// Get the entry that corresponds to the given name. 97 | pub fn get(&self, entry: &str) -> Option<&Entry> { 98 | self.entries.iter().find(|e| e.id.as_ref() == entry) 99 | } 100 | 101 | /// Get a mutable entry that corresponds to the given name. 102 | pub fn get_mut(&mut self, entry: &str) -> Option<&mut Entry> { 103 | self.entries.iter_mut().find(|e| e.id.as_ref() == entry) 104 | } 105 | 106 | /// Attempt to re-read the loader configuration. 107 | pub fn load_conf(&mut self) -> Result<(), Error> { 108 | let &mut SystemdBootConf { 109 | ref mut loader_conf, 110 | ref loader_path, 111 | .. 112 | } = self; 113 | 114 | *loader_conf = LoaderConf::from_path(loader_path).map_err(move |source| Error::Loader { 115 | path: loader_path.to_path_buf(), 116 | source, 117 | })?; 118 | 119 | Ok(()) 120 | } 121 | 122 | /// Attempt to load all of the available entries in the system. 123 | pub fn load_entries(&mut self) -> Result<(), Error> { 124 | let &mut SystemdBootConf { 125 | ref mut entries, 126 | ref entries_path, 127 | .. 128 | } = self; 129 | let dir_entries = fs::read_dir(entries_path).map_err(Error::EntriesDir)?; 130 | 131 | entries.clear(); 132 | for entry in dir_entries { 133 | let entry = entry.map_err(Error::FileEntry)?; 134 | let path = entry.path(); 135 | 136 | // Only consider conf files in the directory. 137 | if !path.is_file() || path.extension().map_or(true, |ext| ext != "conf") { 138 | continue; 139 | } 140 | 141 | let entry = Entry::from_path(&path).map_err(move |source| Error::Entry { 142 | path: path.to_path_buf(), 143 | source, 144 | })?; 145 | 146 | entries.push(entry); 147 | } 148 | 149 | Ok(()) 150 | } 151 | 152 | /// Overwrite the conf file with stored values. 153 | pub fn overwrite_loader_conf(&self) -> Result<(), Error> { 154 | let result = Self::try_io(&self.loader_path, |file| { 155 | if let Some(ref default) = self.loader_conf.default { 156 | writeln!(file, "default {}", default)?; 157 | } 158 | 159 | if let Some(timeout) = self.loader_conf.timeout { 160 | writeln!(file, "timeout {}", timeout)?; 161 | } 162 | 163 | Ok(()) 164 | }); 165 | 166 | result.map_err(Error::LoaderWrite) 167 | } 168 | 169 | /// Overwrite the entry conf for the given entry. 170 | pub fn overwrite_entry_conf(&self, entry: &str) -> Result<(), Error> { 171 | let entry = match self.get(entry) { 172 | Some(entry) => entry, 173 | None => return Err(Error::NotFound), 174 | }; 175 | 176 | let result = Self::try_io( 177 | &self.entries_path.join(format!("{}.conf", entry.id)), 178 | move |file| { 179 | writeln!(file, "title {}", entry.title)?; 180 | writeln!(file, "linux {}", entry.linux)?; 181 | 182 | if let Some(ref initrd) = entry.initrd { 183 | writeln!(file, "initrd {}", initrd)?; 184 | } 185 | 186 | if !entry.options.is_empty() { 187 | writeln!(file, "options: {}", entry.options.join(" "))?; 188 | } 189 | 190 | Ok(()) 191 | }, 192 | ); 193 | 194 | result.map_err(Error::EntryWrite) 195 | } 196 | 197 | fn try_io) -> io::Result<()>>( 198 | path: &Path, 199 | mut instructions: F, 200 | ) -> io::Result<()> { 201 | instructions(&mut BufWriter::new(File::create(path)?)) 202 | } 203 | } 204 | 205 | #[derive(Debug, Copy, Clone)] 206 | pub enum DefaultState { 207 | NotDefined, 208 | Exists, 209 | DoesNotExist, 210 | } 211 | 212 | /// Fetches the kernel command line, and lazily initialize it if it has not been fetched. 213 | pub fn kernel_cmdline() -> &'static [&'static str] { 214 | static CMDLINE_BUF: OnceCell> = OnceCell::new(); 215 | static CMDLINE: OnceCell> = OnceCell::new(); 216 | 217 | CMDLINE.get_or_init(|| { 218 | let cmdline = CMDLINE_BUF.get_or_init(|| { 219 | fs::read_to_string("/proc/cmdline") 220 | .unwrap_or_default() 221 | .into() 222 | }); 223 | 224 | cmdline 225 | .split_ascii_whitespace() 226 | .collect::>() 227 | .into() 228 | }) 229 | } 230 | -------------------------------------------------------------------------------- /src/loader.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{self, BufRead, BufReader}; 3 | use std::path::Path; 4 | 5 | #[derive(Debug, Error)] 6 | pub enum LoaderError { 7 | #[error("error reading line in loader conf")] 8 | Line(#[source] io::Error), 9 | #[error("loader conf is not a file")] 10 | NotAFile, 11 | #[error("default was defined without a value")] 12 | NoValueForDefault, 13 | #[error("timeout was defined without a value")] 14 | NoValueForTimeout, 15 | #[error("error opening loader file")] 16 | Open(#[source] io::Error), 17 | #[error("timeout was defined with a value ({}) which is not a number", _0)] 18 | TimeoutNaN(String), 19 | } 20 | 21 | #[derive(Debug, Default, Clone)] 22 | pub struct LoaderConf { 23 | pub default: Option>, 24 | pub timeout: Option, 25 | } 26 | 27 | impl LoaderConf { 28 | pub fn from_path>(path: P) -> Result { 29 | let path = path.as_ref(); 30 | 31 | let mut loader = LoaderConf::default(); 32 | if !path.exists() { 33 | return Ok(loader); 34 | } 35 | 36 | if !path.is_file() { 37 | return Err(LoaderError::NotAFile); 38 | } 39 | 40 | let file = File::open(path).map_err(LoaderError::Open)?; 41 | 42 | for line in BufReader::new(file).lines() { 43 | let line = line.map_err(LoaderError::Line)?; 44 | let mut fields = line.split_whitespace(); 45 | match fields.next() { 46 | Some("default") => match fields.next() { 47 | Some(default) => loader.default = Some(default.into()), 48 | None => return Err(LoaderError::NoValueForDefault), 49 | }, 50 | Some("timeout") => match fields.next() { 51 | Some(timeout) => { 52 | if let Ok(timeout) = timeout.parse::() { 53 | loader.timeout = Some(timeout); 54 | } else { 55 | return Err(LoaderError::TimeoutNaN(timeout.into())); 56 | } 57 | } 58 | None => return Err(LoaderError::NoValueForTimeout), 59 | }, 60 | _ => (), 61 | } 62 | } 63 | 64 | Ok(loader) 65 | } 66 | } 67 | --------------------------------------------------------------------------------