Skip to main content

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}