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/// Modify kernel arguments in `GRUB_CMDLINE_LINUX_DEFAULT`, back up the
77/// original file, then run `update-grub`.
78///
79/// Risk: High. Kernel argument changes affect every boot. Incorrect arguments
80/// can prevent the system from booting. The helper script writes a backup to
81/// `/etc/default/grub.sysknife.bak` and restores it on `update-grub` failure.
82///
83/// `append` — arguments to add (validated before call).
84/// `delete` — arguments to remove (validated before call).
85///
86/// At least one of `append` / `delete` MUST be non-empty — calling with both
87/// empty is a no-op that still rewrites the GRUB config and runs `update-grub`,
88/// which is wasteful and misleading. The constructor returns
89/// `Err(KargsError::BothEmpty)` in that case.
90///
91/// The underlying mechanism is a single `sudo` invocation of the root-owned
92/// helper script `GRUB_KARGS_HELPER` (no shell involved), which closes the
93/// unconstrained `python3`, `cp`, and `update-grub` sudoers grants.
94#[derive(Debug, thiserror::Error, PartialEq, Eq)]
95pub enum KargsError {
96    #[error("at least one of append or delete must be non-empty")]
97    BothEmpty,
98}
99
100pub fn grub_set_kargs(append: &[&str], delete: &[&str]) -> Result<ActionSpec, KargsError> {
101    if append.is_empty() && delete.is_empty() {
102        return Err(KargsError::BothEmpty);
103    }
104
105    // The helper accepts comma-separated lists. Empty list → empty string after
106    // the flag (the helper treats "" as "no tokens in this set").
107    let append_csv = append.join(",");
108    let delete_csv = delete.join(",");
109
110    Ok(ActionSpec {
111        action_name: "GrubSetKargs",
112        mechanism: command_mechanism(
113            "sudo",
114            [
115                GRUB_KARGS_HELPER,
116                "--append",
117                &append_csv,
118                "--delete",
119                &delete_csv,
120            ],
121        ),
122        risk_level: RiskLevel::High,
123        reboot_required: true,
124        rollback_available: true,
125    })
126}
127
128// ---------------------------------------------------------------------------
129// Tests
130// ---------------------------------------------------------------------------
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::actions::ActionMechanism;
136
137    fn extract_cmd(spec: &ActionSpec) -> (&'static str, Vec<String>) {
138        match &spec.mechanism {
139            ActionMechanism::Command { program, args } => (*program, args.clone()),
140            _ => panic!("expected Command mechanism"),
141        }
142    }
143
144    // ── grub_get_kargs ────────────────────────────────────────────────────────
145
146    #[test]
147    fn grub_get_kargs_action_name() {
148        assert_eq!(grub_get_kargs().action_name, "GrubGetKargs");
149    }
150
151    #[test]
152    fn grub_get_kargs_uses_grep_on_grub_default() {
153        let spec = grub_get_kargs();
154        let (prog, args) = extract_cmd(&spec);
155        assert_eq!(prog, "grep");
156        let joined = args.join(" ");
157        assert!(
158            joined.contains("/etc/default/grub"),
159            "missing grub path: {joined}"
160        );
161        assert!(
162            joined.contains("GRUB_CMDLINE_LINUX"),
163            "missing pattern: {joined}"
164        );
165    }
166
167    #[test]
168    fn grub_get_kargs_risk_is_low() {
169        assert_eq!(grub_get_kargs().risk_level, RiskLevel::Low);
170    }
171
172    #[test]
173    fn grub_get_kargs_no_reboot_no_rollback() {
174        let spec = grub_get_kargs();
175        assert!(!spec.reboot_required);
176        assert!(!spec.rollback_available);
177    }
178
179    // ── grub_set_kargs ────────────────────────────────────────────────────────
180
181    #[test]
182    fn grub_set_kargs_action_name() {
183        assert_eq!(
184            grub_set_kargs(&["quiet"], &[]).unwrap().action_name,
185            "GrubSetKargs"
186        );
187    }
188
189    #[test]
190    fn grub_set_kargs_invokes_sudo_helper() {
191        // The mechanism must be: program=sudo, args[0]=GRUB_KARGS_HELPER.
192        // No bash, no python3, no cp — those are now inside the helper itself.
193        let spec = grub_set_kargs(&["quiet"], &["splash"]).unwrap();
194        let (prog, args) = extract_cmd(&spec);
195        assert_eq!(prog, "sudo", "program must be sudo");
196        assert_eq!(
197            args[0], GRUB_KARGS_HELPER,
198            "first arg must be the helper path"
199        );
200    }
201
202    #[test]
203    fn grub_set_kargs_argv_shape_append_only() {
204        // sudo /usr/lib/sysknife/grub-kargs-edit --append quiet --delete ""
205        let spec = grub_set_kargs(&["quiet"], &[]).unwrap();
206        let (prog, args) = extract_cmd(&spec);
207        assert_eq!(prog, "sudo");
208        assert_eq!(args[0], GRUB_KARGS_HELPER);
209        assert_eq!(args[1], "--append");
210        assert_eq!(args[2], "quiet");
211        assert_eq!(args[3], "--delete");
212        assert_eq!(args[4], "");
213    }
214
215    #[test]
216    fn grub_set_kargs_argv_shape_delete_only() {
217        // sudo /usr/lib/sysknife/grub-kargs-edit --append "" --delete splash
218        let spec = grub_set_kargs(&[], &["splash"]).unwrap();
219        let (prog, args) = extract_cmd(&spec);
220        assert_eq!(prog, "sudo");
221        assert_eq!(args[0], GRUB_KARGS_HELPER);
222        assert_eq!(args[1], "--append");
223        assert_eq!(args[2], "");
224        assert_eq!(args[3], "--delete");
225        assert_eq!(args[4], "splash");
226    }
227
228    #[test]
229    fn grub_set_kargs_argv_shape_both() {
230        // Multiple kargs join as comma-separated CSV.
231        let spec = grub_set_kargs(&["nomodeset", "quiet"], &["splash", "quiet"]).unwrap();
232        let (prog, args) = extract_cmd(&spec);
233        assert_eq!(prog, "sudo");
234        assert_eq!(args[0], GRUB_KARGS_HELPER);
235        assert_eq!(args[2], "nomodeset,quiet", "append CSV");
236        assert_eq!(args[4], "splash,quiet", "delete CSV");
237    }
238
239    #[test]
240    fn grub_set_kargs_risk_is_high() {
241        assert_eq!(
242            grub_set_kargs(&["quiet"], &[]).unwrap().risk_level,
243            RiskLevel::High
244        );
245    }
246
247    #[test]
248    fn grub_set_kargs_reboot_required() {
249        assert!(grub_set_kargs(&["quiet"], &[]).unwrap().reboot_required);
250    }
251
252    #[test]
253    fn grub_set_kargs_rollback_available() {
254        assert!(grub_set_kargs(&["quiet"], &[]).unwrap().rollback_available);
255    }
256
257    #[test]
258    fn grub_set_kargs_rejects_both_empty() {
259        // The constructor — not just the executor — enforces "at least one of
260        // append/delete must be non-empty". A direct Rust caller can't bypass.
261        let err = grub_set_kargs(&[], &[]).unwrap_err();
262        assert_eq!(err, KargsError::BothEmpty);
263    }
264
265    // ── specs() completeness ─────────────────────────────────────────────────
266
267    #[test]
268    fn specs_covers_all_action_names() {
269        let expected = ["GrubGetKargs", "GrubSetKargs"];
270        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
271        for name in &expected {
272            assert!(spec_names.contains(name), "specs() missing {name}");
273        }
274    }
275}