kexec_loader/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3// Phase 6 of #286 — README.md becomes the rustdoc landing page.
4// `clippy::doc_markdown = allow` — README prose; see iso-parser for
5// rationale.
6#![allow(clippy::doc_markdown)]
7#![doc = include_str!("../README.md")]
8//!
9//! ---
10//!
11//! # Rust API
12//!
13//! Safe wrapper around `kexec_file_load(2)` for the aegis-boot rescue TUI.
14//!
15//! # Scope
16//!
17//! Only the file-descriptor-based [`kexec_file_load(2)`] syscall is supported.
18//! The classic [`kexec_load(2)`] is intentionally **not** exposed:
19//!
20//! - It is blocked under `lockdown=integrity` (which we require).
21//! - It has no upstream signature-verification story — `KEXEC_SIG` only
22//! applies to `kexec_file_load`.
23//!
24//! See [ADR 0001](https://github.com/aegis-boot/aegis-boot/blob/main/docs/adr/0001-runtime-architecture.md)
25//! for the Secure Boot rationale.
26//!
27//! # Safety
28//!
29//! This crate opts into `unsafe` narrowly (see [`syscall`] module) to invoke
30//! `kexec_file_load(2)` and `reboot(2)`. Every unsafe block documents its
31//! invariant. The rest of the workspace is `unsafe_code = forbid`.
32//!
33//! [`kexec_file_load(2)`]: https://man7.org/linux/man-pages/man2/kexec_file_load.2.html
34//! [`kexec_load(2)`]: https://man7.org/linux/man-pages/man2/kexec_load.2.html
35
36use std::ffi::CString;
37use std::io;
38use std::path::{Path, PathBuf};
39
40#[cfg(target_os = "linux")]
41mod syscall;
42
43/// Parameters for a `kexec_file_load` invocation.
44#[derive(Debug, Clone)]
45pub struct KexecRequest {
46 /// Path to the target kernel image. Must be signed by a key in the
47 /// platform or MOK keyring when Secure Boot is enforced.
48 pub kernel: PathBuf,
49 /// Optional initrd path.
50 pub initrd: Option<PathBuf>,
51 /// Kernel command line.
52 pub cmdline: String,
53}
54
55/// Errors returned while preparing or invoking kexec.
56///
57/// Classification is deliberately narrow so the TUI can render a specific,
58/// user-actionable diagnostic instead of a black screen.
59#[derive(Debug, thiserror::Error)]
60pub enum KexecError {
61 /// Kernel signature verification (`KEXEC_SIG`) rejected the image.
62 ///
63 /// Maps to `EKEYREJECTED` from `kexec_file_load(2)`. Typical cause: the
64 /// ISO's kernel is self-signed or signed by a CA not present in the
65 /// platform / MOK keyring. User remedy: enroll the key via `mokutil`.
66 #[error("kernel signature verification failed (KEXEC_SIG rejected the image)")]
67 SignatureRejected,
68
69 /// Kernel lockdown / Secure Boot refused the operation.
70 ///
71 /// Maps to `EPERM` from `kexec_file_load(2)` when lockdown is integrity
72 /// or confidentiality mode. User remedy: none — by design.
73 #[error("operation refused by kernel lockdown (Secure Boot enforcing)")]
74 LockdownRefused,
75
76 /// Image format rejected by the kernel's file loader (e.g. not a bzImage).
77 ///
78 /// Maps to `ENOEXEC`.
79 #[error("kernel image format not recognized (kexec_file_load returned ENOEXEC)")]
80 UnsupportedImage,
81
82 /// Underlying I/O or file-descriptor failure.
83 #[error("io error: {0}")]
84 Io(#[from] io::Error),
85
86 /// Caller supplied a path containing an interior NUL byte.
87 #[error("path contains interior NUL byte: {0}")]
88 InvalidPath(PathBuf),
89
90 /// Crate compiled for a non-Linux target; no kexec available.
91 #[error("kexec is only available on Linux")]
92 Unsupported,
93}
94
95/// Load the requested kernel via `kexec_file_load(2)` without triggering the
96/// subsequent reboot.
97///
98/// On success the image is staged in kernel memory and
99/// `/sys/kernel/kexec_loaded` flips to `1`. Callers that want to actually
100/// hand off to the loaded kernel should use [`load_and_exec`]; this entry
101/// point exists so integration tests can verify the syscall path against a
102/// real kernel without replacing the test process.
103///
104/// # Errors
105///
106/// See [`KexecError`].
107#[cfg(target_os = "linux")]
108pub fn load_dry(req: &KexecRequest) -> Result<(), KexecError> {
109 let kernel_fd = open_path(&req.kernel)?;
110 let initrd_fd = req.initrd.as_deref().map(open_path).transpose()?;
111 let cmdline = CString::new(req.cmdline.as_bytes())
112 .map_err(|_| KexecError::InvalidPath(PathBuf::from(&req.cmdline)))?;
113 syscall::kexec_file_load(
114 kernel_fd.as_raw(),
115 initrd_fd.as_ref().map(OwnedFd::as_raw),
116 &cmdline,
117 )
118}
119
120/// Non-Linux stub — always returns [`KexecError::Unsupported`].
121#[cfg(not(target_os = "linux"))]
122pub fn load_dry(_req: &KexecRequest) -> Result<(), KexecError> {
123 Err(KexecError::Unsupported)
124}
125
126/// Load the requested kernel via `kexec_file_load(2)` and immediately trigger
127/// `reboot(LINUX_REBOOT_CMD_KEXEC)`.
128///
129/// On success this function **does not return** — the calling process is
130/// replaced by the new kernel. Returning [`Ok`] with [`std::convert::Infallible`]
131/// is unreachable in practice; the type signature documents the intent.
132///
133/// # Errors
134///
135/// See [`KexecError`] — every non-success path is classified so the caller
136/// can present a specific diagnostic.
137#[cfg(target_os = "linux")]
138pub fn load_and_exec(req: &KexecRequest) -> Result<std::convert::Infallible, KexecError> {
139 load_dry(req)?;
140 syscall::reboot_kexec()?;
141 // reboot_kexec never returns on success. If we got here, treat as Io.
142 Err(KexecError::Io(io::Error::other(
143 "reboot(LINUX_REBOOT_CMD_KEXEC) returned unexpectedly",
144 )))
145}
146
147/// Non-Linux stub — always returns [`KexecError::Unsupported`].
148#[cfg(not(target_os = "linux"))]
149pub fn load_and_exec(_req: &KexecRequest) -> Result<std::convert::Infallible, KexecError> {
150 Err(KexecError::Unsupported)
151}
152
153/// RAII owned file descriptor. Closes on drop.
154///
155/// We don't use `std::os::fd::OwnedFd` directly so the drop behavior and
156/// raw-fd extraction can be reviewed in one place alongside the syscall.
157#[cfg(target_os = "linux")]
158struct OwnedFd(libc::c_int);
159
160#[cfg(target_os = "linux")]
161impl OwnedFd {
162 fn as_raw(&self) -> libc::c_int {
163 self.0
164 }
165}
166
167#[cfg(target_os = "linux")]
168impl Drop for OwnedFd {
169 fn drop(&mut self) {
170 // SAFETY: fd was obtained from `open(2)` in `open_path` and is not
171 // shared. Closing an already-closed or never-opened fd would be UB,
172 // but construction paths guarantee `self.0 >= 0` and single-owner.
173 #[allow(unsafe_code)]
174 // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
175 unsafe {
176 libc::close(self.0);
177 }
178 }
179}
180
181#[cfg(target_os = "linux")]
182fn open_path(path: &Path) -> Result<OwnedFd, KexecError> {
183 let c_path = path_to_cstring(path)?;
184 // SAFETY: `c_path` is a valid NUL-terminated C string pointing to an
185 // owned buffer that outlives the syscall. `O_RDONLY | O_CLOEXEC` is a
186 // safe flag combination. Return value is checked below.
187 #[allow(unsafe_code)]
188 // nosemgrep: rust.lang.security.unsafe-usage.unsafe-usage
189 let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
190 if fd < 0 {
191 return Err(KexecError::Io(io::Error::last_os_error()));
192 }
193 Ok(OwnedFd(fd))
194}
195
196fn path_to_cstring(path: &Path) -> Result<CString, KexecError> {
197 use std::os::unix::ffi::OsStrExt;
198 CString::new(path.as_os_str().as_bytes())
199 .map_err(|_| KexecError::InvalidPath(path.to_path_buf()))
200}
201
202/// Classify a raw `errno` from `kexec_file_load(2)` into [`KexecError`].
203///
204/// Exposed so tests can verify the mapping without issuing the real syscall.
205#[must_use]
206pub fn classify_errno(errno: i32) -> KexecError {
207 match errno {
208 libc::EKEYREJECTED => KexecError::SignatureRejected,
209 libc::EPERM => KexecError::LockdownRefused,
210 libc::ENOEXEC => KexecError::UnsupportedImage,
211 other => KexecError::Io(io::Error::from_raw_os_error(other)),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn classify_signature_rejection() {
221 assert!(matches!(
222 classify_errno(libc::EKEYREJECTED),
223 KexecError::SignatureRejected
224 ));
225 }
226
227 #[test]
228 fn classify_lockdown() {
229 assert!(matches!(
230 classify_errno(libc::EPERM),
231 KexecError::LockdownRefused
232 ));
233 }
234
235 #[test]
236 fn classify_bad_image() {
237 assert!(matches!(
238 classify_errno(libc::ENOEXEC),
239 KexecError::UnsupportedImage
240 ));
241 }
242
243 #[test]
244 fn classify_generic_io_preserves_errno() {
245 let err = classify_errno(libc::ENOENT);
246 let KexecError::Io(io_err) = err else {
247 panic!("expected Io variant");
248 };
249 assert_eq!(io_err.raw_os_error(), Some(libc::ENOENT));
250 }
251
252 #[test]
253 fn path_ok_round_trips() {
254 let ok = Path::new("/boot/vmlinuz-rescue");
255 let c = path_to_cstring(ok).unwrap_or_else(|_| panic!("valid path"));
256 assert_eq!(c.to_bytes(), b"/boot/vmlinuz-rescue");
257 }
258
259 #[test]
260 fn path_with_nul_byte_rejected() {
261 let bad = Path::new("/tmp/has\0nul");
262 assert!(matches!(
263 path_to_cstring(bad),
264 Err(KexecError::InvalidPath(_))
265 ));
266 }
267
268 /// Integration guard: exercising the real syscall requires `CAP_SYS_BOOT`
269 /// and will reboot the machine on success. Run manually only.
270 #[test]
271 #[ignore = "requires root + would kexec the host; opt-in via `cargo test -- --ignored`"]
272 #[cfg(target_os = "linux")]
273 fn load_and_exec_rejects_nonexistent_kernel() {
274 let req = KexecRequest {
275 kernel: PathBuf::from("/nonexistent/vmlinuz"),
276 initrd: None,
277 cmdline: String::new(),
278 };
279 let err = load_and_exec(&req).expect_err("must fail");
280 assert!(matches!(err, KexecError::Io(_)));
281 }
282}