Skip to main content

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}