sysknife_daemon/actions/grub.rs
1//! GRUB kernel argument actions (Ubuntu).
2//!
3//! ## GrubGetKargs
4//!
5//! Read-only inspection of `GRUB_CMDLINE_LINUX` in `/etc/default/grub`.
6//! No system changes are made.
7//!
8//! ## GrubSetKargs
9//!
10//! Modifies `GRUB_CMDLINE_LINUX_DEFAULT` in `/etc/default/grub`, then runs
11//! `update-grub` to regenerate the GRUB config.
12//!
13//! **Backup:** before the edit, the current `/etc/default/grub` is backed up
14//! to `/etc/default/grub.sysknife.bak` by the helper. On `update-grub`
15//! failure the helper restores the backup automatically.
16//!
17//! **Helper script:** the entire operation is delegated to the root-owned
18//! helper script `/usr/lib/sysknife/grub-kargs-edit` (installed from
19//! `packaging/sysknife-grub-kargs-edit`). This replaces the previous
20//! `bash -c` pipeline approach and eliminates the three unconstrained sudo
21//! grants for `python3`, `cp`, and `update-grub` (red-team HI1/HI2/HI3).
22//! The sudoers entry grants NOPASSWD only on the exact helper path:
23//! `sysknife ALL=(root) NOPASSWD: /usr/lib/sysknife/grub-kargs-edit *`
24//!
25//! **Reboot required:** kernel argument changes do not take effect until the
26//! next boot.
27//!
28//! **Params:**
29//! - `append`: `Vec<String>` — args to add (merged into the existing line).
30//! - `delete`: `Vec<String>` — args to remove from the existing line.
31//! Either list may be empty; at least one must be non-empty.
32
33use super::{command_mechanism, ActionSpec};
34use sysknife_types::RiskLevel;
35
36// ---------------------------------------------------------------------------
37// Constants
38// ---------------------------------------------------------------------------
39
40/// Installed path of the privileged GRUB kargs helper script.
41///
42/// The helper is installed to this path with mode 0755 and owned by root.
43/// See `packaging/sysknife-grub-kargs-edit` for the source and
44/// `packaging/sysknife-sudoers` for the corresponding NOPASSWD grant.
45const GRUB_KARGS_HELPER: &str = "/usr/lib/sysknife/grub-kargs-edit";
46
47// ---------------------------------------------------------------------------
48// specs() — for action_consistency tests
49// ---------------------------------------------------------------------------
50
51/// Return one representative `ActionSpec` per GRUB action name.
52pub fn specs() -> Vec<ActionSpec> {
53 vec![
54 grub_get_kargs(),
55 grub_set_kargs(&["quiet"], &["splash"]).expect("non-empty kargs"),
56 ]
57}
58
59// ---------------------------------------------------------------------------
60// Action constructors
61// ---------------------------------------------------------------------------
62
63/// Read the `GRUB_CMDLINE_LINUX` line from `/etc/default/grub`.
64///
65/// Risk: Low. Read-only file inspection; no system changes.
66pub fn grub_get_kargs() -> ActionSpec {
67 ActionSpec {
68 action_name: "GrubGetKargs",
69 mechanism: command_mechanism("grep", ["-E", r"^GRUB_CMDLINE_LINUX", "/etc/default/grub"]),
70 risk_level: RiskLevel::Low,
71 reboot_required: false,
72 rollback_available: false,
73 }
74}
75
76/// Error returned by [`grub_set_kargs`] when both `append` and `delete` are
77/// empty.
78#[derive(Debug, thiserror::Error, PartialEq, Eq)]
79pub enum KargsError {
80 #[error("at least one of append or delete must be non-empty")]
81 BothEmpty,
82}
83
84/// Modify kernel arguments in `GRUB_CMDLINE_LINUX_DEFAULT`, back up the
85/// original file, then run `update-grub`.
86///
87/// Risk: High. Kernel argument changes affect every boot. Incorrect arguments
88/// can prevent the system from booting. The helper script writes a backup to
89/// `/etc/default/grub.sysknife.bak` and restores it on `update-grub` failure.
90///
91/// `rollback_available` is false: that backup-and-restore is scoped to a
92/// single failed `update-grub` run inside the helper, not the daemon's
93/// automatic rollback mechanism (`executor::rollback_spec_for`), which today
94/// only covers rpm-ostree deployment actions (see
95/// `docs/automatic-rollback.md`). The daemon does not automatically revert a
96/// *successful* `GrubSetKargs` call that later turns out to be wrong.
97///
98/// `append` — arguments to add (validated before call).
99/// `delete` — arguments to remove (validated before call).
100///
101/// At least one of `append` / `delete` MUST be non-empty — calling with both
102/// empty is a no-op that still rewrites the GRUB config and runs `update-grub`,
103/// which is wasteful and misleading. The constructor returns
104/// `Err(KargsError::BothEmpty)` in that case.
105///
106/// The underlying mechanism is a single `sudo` invocation of the root-owned
107/// helper script `GRUB_KARGS_HELPER` (no shell involved), which closes the
108/// unconstrained `python3`, `cp`, and `update-grub` sudoers grants.
109pub fn grub_set_kargs(append: &[&str], delete: &[&str]) -> Result<ActionSpec, KargsError> {
110 if append.is_empty() && delete.is_empty() {
111 return Err(KargsError::BothEmpty);
112 }
113
114 // The helper accepts comma-separated lists. Empty list → empty string after
115 // the flag (the helper treats "" as "no tokens in this set").
116 let append_csv = append.join(",");
117 let delete_csv = delete.join(",");
118
119 Ok(ActionSpec {
120 action_name: "GrubSetKargs",
121 mechanism: command_mechanism(
122 "sudo",
123 [
124 GRUB_KARGS_HELPER,
125 "--append",
126 &append_csv,
127 "--delete",
128 &delete_csv,
129 ],
130 ),
131 risk_level: RiskLevel::High,
132 reboot_required: true,
133 rollback_available: false,
134 })
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::actions::ActionMechanism;
145
146 fn extract_cmd(spec: &ActionSpec) -> (&'static str, Vec<String>) {
147 match &spec.mechanism {
148 ActionMechanism::Command { program, args } => (*program, args.clone()),
149 _ => panic!("expected Command mechanism"),
150 }
151 }
152
153 // ── grub_get_kargs ────────────────────────────────────────────────────────
154
155 #[test]
156 fn grub_get_kargs_action_name() {
157 assert_eq!(grub_get_kargs().action_name, "GrubGetKargs");
158 }
159
160 #[test]
161 fn grub_get_kargs_uses_grep_on_grub_default() {
162 let spec = grub_get_kargs();
163 let (prog, args) = extract_cmd(&spec);
164 assert_eq!(prog, "grep");
165 let joined = args.join(" ");
166 assert!(
167 joined.contains("/etc/default/grub"),
168 "missing grub path: {joined}"
169 );
170 assert!(
171 joined.contains("GRUB_CMDLINE_LINUX"),
172 "missing pattern: {joined}"
173 );
174 }
175
176 #[test]
177 fn grub_get_kargs_risk_is_low() {
178 assert_eq!(grub_get_kargs().risk_level, RiskLevel::Low);
179 }
180
181 #[test]
182 fn grub_get_kargs_no_reboot_no_rollback() {
183 let spec = grub_get_kargs();
184 assert!(!spec.reboot_required);
185 assert!(!spec.rollback_available);
186 }
187
188 // ── grub_set_kargs ────────────────────────────────────────────────────────
189
190 #[test]
191 fn grub_set_kargs_action_name() {
192 assert_eq!(
193 grub_set_kargs(&["quiet"], &[]).unwrap().action_name,
194 "GrubSetKargs"
195 );
196 }
197
198 #[test]
199 fn grub_set_kargs_invokes_sudo_helper() {
200 // The mechanism must be: program=sudo, args[0]=GRUB_KARGS_HELPER.
201 // No bash, no python3, no cp — those are now inside the helper itself.
202 let spec = grub_set_kargs(&["quiet"], &["splash"]).unwrap();
203 let (prog, args) = extract_cmd(&spec);
204 assert_eq!(prog, "sudo", "program must be sudo");
205 assert_eq!(
206 args[0], GRUB_KARGS_HELPER,
207 "first arg must be the helper path"
208 );
209 }
210
211 #[test]
212 fn grub_set_kargs_argv_shape_append_only() {
213 // sudo /usr/lib/sysknife/grub-kargs-edit --append quiet --delete ""
214 let spec = grub_set_kargs(&["quiet"], &[]).unwrap();
215 let (prog, args) = extract_cmd(&spec);
216 assert_eq!(prog, "sudo");
217 assert_eq!(args[0], GRUB_KARGS_HELPER);
218 assert_eq!(args[1], "--append");
219 assert_eq!(args[2], "quiet");
220 assert_eq!(args[3], "--delete");
221 assert_eq!(args[4], "");
222 }
223
224 #[test]
225 fn grub_set_kargs_argv_shape_delete_only() {
226 // sudo /usr/lib/sysknife/grub-kargs-edit --append "" --delete splash
227 let spec = grub_set_kargs(&[], &["splash"]).unwrap();
228 let (prog, args) = extract_cmd(&spec);
229 assert_eq!(prog, "sudo");
230 assert_eq!(args[0], GRUB_KARGS_HELPER);
231 assert_eq!(args[1], "--append");
232 assert_eq!(args[2], "");
233 assert_eq!(args[3], "--delete");
234 assert_eq!(args[4], "splash");
235 }
236
237 #[test]
238 fn grub_set_kargs_argv_shape_both() {
239 // Multiple kargs join as comma-separated CSV.
240 let spec = grub_set_kargs(&["nomodeset", "quiet"], &["splash", "quiet"]).unwrap();
241 let (prog, args) = extract_cmd(&spec);
242 assert_eq!(prog, "sudo");
243 assert_eq!(args[0], GRUB_KARGS_HELPER);
244 assert_eq!(args[2], "nomodeset,quiet", "append CSV");
245 assert_eq!(args[4], "splash,quiet", "delete CSV");
246 }
247
248 #[test]
249 fn grub_set_kargs_risk_is_high() {
250 assert_eq!(
251 grub_set_kargs(&["quiet"], &[]).unwrap().risk_level,
252 RiskLevel::High
253 );
254 }
255
256 #[test]
257 fn grub_set_kargs_reboot_required() {
258 assert!(grub_set_kargs(&["quiet"], &[]).unwrap().reboot_required);
259 }
260
261 #[test]
262 fn grub_set_kargs_no_automatic_rollback() {
263 // The helper's backup/restore only covers a failed `update-grub` run
264 // within the same call; the daemon's automatic-rollback mechanism
265 // (rollback_available) only exists for rpm-ostree deployment actions.
266 assert!(!grub_set_kargs(&["quiet"], &[]).unwrap().rollback_available);
267 }
268
269 #[test]
270 fn grub_set_kargs_rejects_both_empty() {
271 // The constructor — not just the executor — enforces "at least one of
272 // append/delete must be non-empty". A direct Rust caller can't bypass.
273 let err = grub_set_kargs(&[], &[]).unwrap_err();
274 assert_eq!(err, KargsError::BothEmpty);
275 }
276
277 // ── specs() completeness ─────────────────────────────────────────────────
278
279 #[test]
280 fn specs_covers_all_action_names() {
281 let expected = ["GrubGetKargs", "GrubSetKargs"];
282 let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
283 for name in &expected {
284 assert!(spec_names.contains(name), "specs() missing {name}");
285 }
286 }
287}