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}