Skip to main content

sysknife_daemon/actions/
snap.rs

1//! snap package management actions (Ubuntu).
2//!
3//! ## Auto-refresh footgun
4//!
5//! snapd refreshes snaps automatically on a schedule. When a snap is installed
6//! for a specific purpose (e.g. a pinned version of a tool), an unexpected
7//! snap refresh can break the workload without operator approval.
8//!
9//! Mitigation: `SnapInstall` by default pairs the install with
10//! `snap refresh --hold <name>` to pin the snap at the installed version.
11//! Set `auto_update: true` in the plan params to skip the hold.
12//!
13//! The bounded helper installs and then holds, stopping on either failure.
14//! `name` and `channel` are validated by [`snap_install`] and the helper
15//! (in addition to, not instead of, the executor's own `validated_safe_arg`
16//! check) — see the `SnapInstallError` doc below.
17
18use super::{command_mechanism, ActionSpec};
19use sysknife_types::RiskLevel;
20
21// ---------------------------------------------------------------------------
22// Error type
23// ---------------------------------------------------------------------------
24
25/// Returned when `snap_install`'s `name` or `channel` fails validation.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum SnapInstallError {
28    /// `name`/`channel` contains a shell metacharacter or otherwise fails the
29    /// safe-arg allowlist. Carries the offending parameter and value.
30    ///
31    /// Defense in depth: the executor already validates both via
32    /// `validated_safe_arg` before calling this constructor, but the
33    /// helper also revalidates them. Keep constructor validation so internal
34    /// callers cannot produce an invalid spec by skipping the executor.
35    InvalidArg { param: &'static str, value: String },
36}
37
38impl std::fmt::Display for SnapInstallError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            Self::InvalidArg { param, value } => write!(f, "invalid {param}: '{value}'"),
42        }
43    }
44}
45
46impl std::error::Error for SnapInstallError {}
47
48/// Allowlist for `name`/`channel`: alphanumeric plus `._:/+@-`, no leading
49/// dash, 1-254 bytes. Mirrors `validated_safe_arg`'s charset exactly, kept as
50/// a self-contained check here (rather than depending on `crate::executor`)
51/// so this action module has no dependency on the executor's error type —
52/// the same pattern `fail2ban::jail_is_valid` uses.
53fn safe_snap_arg_is_valid(s: &str) -> bool {
54    if s.is_empty() || s.len() > 254 || s.starts_with('-') {
55        return false;
56    }
57    s.chars()
58        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
59}
60
61// ---------------------------------------------------------------------------
62// specs() — for action_consistency tests
63// ---------------------------------------------------------------------------
64
65/// Return one representative `ActionSpec` per snap action name.
66pub fn specs() -> Vec<ActionSpec> {
67    vec![
68        snap_install("firefox", None, false).expect("firefox is a valid snap arg"),
69        snap_remove("firefox"),
70        snap_refresh(Some("firefox")),
71        snap_hold("firefox"),
72        snap_unhold("firefox"),
73        snap_list(),
74        snap_info("firefox"),
75        snap_revert("firefox"),
76        snap_classic_install("code"),
77    ]
78}
79
80// ---------------------------------------------------------------------------
81// Action constructors
82// ---------------------------------------------------------------------------
83
84/// Install a snap (`snap install [--channel=<channel>] <name>`) and,
85/// unless `auto_update` is true, immediately hold it to prevent auto-refresh.
86///
87/// Risk: Medium. Installs snaps from the Snap Store. Sandboxed but can access
88/// system resources depending on interface connections.
89///
90/// `channel` defaults to `stable` when `None`.
91///
92/// Returns `Err(SnapInstallError::InvalidArg)` if `name` or `channel` fails
93/// the safe-arg allowlist — see [`SnapInstallError`].
94pub fn snap_install(
95    name: &str,
96    channel: Option<&str>,
97    auto_update: bool,
98) -> Result<ActionSpec, SnapInstallError> {
99    if !safe_snap_arg_is_valid(name) {
100        return Err(SnapInstallError::InvalidArg {
101            param: "name",
102            value: name.to_string(),
103        });
104    }
105    if let Some(ch) = channel {
106        if !safe_snap_arg_is_valid(ch) {
107            return Err(SnapInstallError::InvalidArg {
108                param: "channel",
109                value: ch.to_string(),
110            });
111        }
112    }
113
114    if auto_update {
115        // Plain install without hold.
116        let mut args = vec!["install".to_string()];
117        if let Some(ch) = channel {
118            args.push(format!("--channel={}", ch));
119        }
120        args.push(name.to_string());
121        Ok(ActionSpec {
122            action_name: "SnapInstall",
123            mechanism: command_mechanism(
124                "sudo",
125                std::iter::once("snap").chain(args.iter().map(String::as_str)),
126            ),
127            risk_level: RiskLevel::Medium,
128            reboot_required: false,
129            rollback_available: false,
130        })
131    } else {
132        // Sequential install and hold: the helper does not hold a failed install.
133        let channel_arg = channel.unwrap_or("stable");
134        Ok(ActionSpec {
135            action_name: "SnapInstall",
136            mechanism: super::command_mechanism(
137                "sudo",
138                [
139                    "/usr/lib/sysknife/action-steps",
140                    "snap-install-hold",
141                    name,
142                    channel_arg,
143                ],
144            ),
145            risk_level: RiskLevel::Medium,
146            reboot_required: false,
147            rollback_available: false,
148        })
149    }
150}
151
152/// Remove a snap (`snap remove <name>`).
153///
154/// Risk: Medium. Uninstalls the snap and removes its data.
155pub fn snap_remove(name: &str) -> ActionSpec {
156    ActionSpec {
157        action_name: "SnapRemove",
158        mechanism: command_mechanism("sudo", ["snap", "remove", name]),
159        risk_level: RiskLevel::Medium,
160        reboot_required: false,
161        rollback_available: false,
162    }
163}
164
165/// Refresh a snap or all snaps.
166///
167/// `name = Some("firefox")` → refresh only that snap.
168/// `name = None` → refresh all snaps.
169///
170/// Risk: Medium. Updates snap(s); may change behaviour.
171pub fn snap_refresh(name: Option<&str>) -> ActionSpec {
172    match name {
173        Some(n) => ActionSpec {
174            action_name: "SnapRefresh",
175            mechanism: command_mechanism("sudo", ["snap", "refresh", n]),
176            risk_level: RiskLevel::Medium,
177            reboot_required: false,
178            rollback_available: false,
179        },
180        None => ActionSpec {
181            action_name: "SnapRefresh",
182            mechanism: command_mechanism("sudo", ["snap", "refresh"]),
183            risk_level: RiskLevel::Medium,
184            reboot_required: false,
185            rollback_available: false,
186        },
187    }
188}
189
190/// Hold a snap at its current version, preventing auto-refresh.
191///
192/// Risk: Medium. Freezes the snap version. The snap remains installed.
193pub fn snap_hold(name: &str) -> ActionSpec {
194    ActionSpec {
195        action_name: "SnapHold",
196        mechanism: command_mechanism("sudo", ["snap", "refresh", "--hold", name]),
197        risk_level: RiskLevel::Medium,
198        reboot_required: false,
199        rollback_available: false,
200    }
201}
202
203/// Remove a hold from a snap, allowing auto-refresh again.
204///
205/// Risk: Medium. After unholding, the next snapd refresh cycle can update the
206/// snap without further operator approval.
207pub fn snap_unhold(name: &str) -> ActionSpec {
208    ActionSpec {
209        action_name: "SnapUnhold",
210        mechanism: command_mechanism("sudo", ["snap", "refresh", "--unhold", name]),
211        risk_level: RiskLevel::Medium,
212        reboot_required: false,
213        rollback_available: false,
214    }
215}
216
217/// List installed snaps (`snap list`).
218///
219/// Risk: Low. Read-only query.
220pub fn snap_list() -> ActionSpec {
221    ActionSpec {
222        action_name: "SnapList",
223        mechanism: command_mechanism("snap", ["list"]),
224        risk_level: RiskLevel::Low,
225        reboot_required: false,
226        rollback_available: false,
227    }
228}
229
230/// Show detailed information about a snap (`snap info <name>`).
231///
232/// Risk: Low. Read-only query.
233pub fn snap_info(name: &str) -> ActionSpec {
234    ActionSpec {
235        action_name: "SnapInfo",
236        mechanism: command_mechanism("snap", ["info", name]),
237        risk_level: RiskLevel::Low,
238        reboot_required: false,
239        rollback_available: false,
240    }
241}
242
243/// Revert a snap to the previously active revision (`snap revert <name>`).
244///
245/// Risk: Medium. Rolls the snap back one revision; the current revision is
246/// preserved and the revert itself can be undone by refreshing again.
247pub fn snap_revert(name: &str) -> ActionSpec {
248    ActionSpec {
249        action_name: "SnapRevert",
250        mechanism: command_mechanism("sudo", ["snap", "revert", name]),
251        risk_level: RiskLevel::Medium,
252        reboot_required: false,
253        rollback_available: false,
254    }
255}
256
257/// Install a snap that requires classic confinement (`snap install --classic <name>`).
258///
259/// Risk: Medium. Classic-confined snaps have full system access (no sandbox);
260/// this carries more risk than sandboxed snap installs.
261pub fn snap_classic_install(name: &str) -> ActionSpec {
262    ActionSpec {
263        action_name: "SnapClassicInstall",
264        mechanism: command_mechanism("sudo", ["snap", "install", "--classic", name]),
265        risk_level: RiskLevel::Medium,
266        reboot_required: false,
267        rollback_available: false,
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Tests
273// ---------------------------------------------------------------------------
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::actions::ActionMechanism;
279
280    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
281        match &spec.mechanism {
282            ActionMechanism::Command { program, args } => (*program, args.clone()),
283            _ => panic!("expected Command mechanism"),
284        }
285    }
286
287    // ── snap_install ─────────────────────────────────────────────────────────
288
289    #[test]
290    fn snap_install_action_name() {
291        assert_eq!(
292            snap_install("firefox", None, false).unwrap().action_name,
293            "SnapInstall"
294        );
295    }
296
297    #[test]
298    fn snap_install_default_includes_hold() {
299        let spec = snap_install("firefox", None, false).unwrap();
300        let (prog, args) = extract_args(&spec);
301        assert_eq!(prog, "sudo");
302        assert_eq!(
303            args,
304            [
305                "/usr/lib/sysknife/action-steps",
306                "snap-install-hold",
307                "firefox",
308                "stable"
309            ]
310        );
311        assert_eq!(
312            crate::actions::exclusive_resource(&spec),
313            Some(crate::actions::ExclusiveResource::Snap)
314        );
315    }
316
317    #[test]
318    fn snap_install_auto_update_no_hold() {
319        let spec = snap_install("firefox", None, true).unwrap();
320        let (prog, args) = extract_args(&spec);
321        assert_eq!(prog, "sudo");
322        let full = args.join(" ");
323        // Must install but must NOT hold.
324        assert!(full.contains("install"), "missing install: {full}");
325        assert!(
326            !full.contains("hold"),
327            "unexpected hold in auto_update=true: {full}"
328        );
329    }
330
331    #[test]
332    fn snap_install_custom_channel() {
333        let spec = snap_install("firefox", Some("beta"), false).unwrap();
334        let (_, args) = extract_args(&spec);
335        let full = args.join(" ");
336        assert!(full.contains("beta"), "channel not present: {full}");
337    }
338
339    #[test]
340    fn snap_install_risk_medium() {
341        assert_eq!(
342            snap_install("vim", None, false).unwrap().risk_level,
343            RiskLevel::Medium
344        );
345    }
346
347    #[test]
348    fn snap_install_rejects_shell_metacharacters_in_name() {
349        let err = snap_install("firefox; rm -rf /", None, false).unwrap_err();
350        assert!(matches!(
351            err,
352            SnapInstallError::InvalidArg { param: "name", .. }
353        ));
354    }
355
356    #[test]
357    fn snap_install_rejects_shell_metacharacters_in_channel() {
358        let err = snap_install("firefox", Some("beta && evil"), false).unwrap_err();
359        assert!(matches!(
360            err,
361            SnapInstallError::InvalidArg {
362                param: "channel",
363                ..
364            }
365        ));
366    }
367
368    #[test]
369    fn snap_install_rejects_leading_dash_name() {
370        // Option-injection guard: mirrors validated_safe_arg's leading-dash rule.
371        let err = snap_install("--classic", None, false).unwrap_err();
372        assert!(matches!(
373            err,
374            SnapInstallError::InvalidArg { param: "name", .. }
375        ));
376    }
377
378    // ── snap_remove ──────────────────────────────────────────────────────────
379
380    #[test]
381    fn snap_remove_action_name() {
382        assert_eq!(snap_remove("vim").action_name, "SnapRemove");
383    }
384
385    #[test]
386    fn snap_remove_argv() {
387        let spec = snap_remove("vim");
388        let (prog, args) = extract_args(&spec);
389        assert_eq!(prog, "sudo");
390        let a: Vec<&str> = args.iter().map(String::as_str).collect();
391        assert!(a.contains(&"snap"));
392        assert!(a.contains(&"remove"));
393        assert!(a.contains(&"vim"));
394    }
395
396    #[test]
397    fn snap_remove_risk_medium() {
398        assert_eq!(snap_remove("vim").risk_level, RiskLevel::Medium);
399    }
400
401    // ── snap_refresh ─────────────────────────────────────────────────────────
402
403    #[test]
404    fn snap_refresh_named() {
405        let spec = snap_refresh(Some("firefox"));
406        let (_, args) = extract_args(&spec);
407        let a: Vec<&str> = args.iter().map(String::as_str).collect();
408        assert!(a.contains(&"firefox"));
409    }
410
411    #[test]
412    fn snap_refresh_all_no_extra_args() {
413        let spec = snap_refresh(None);
414        let (_, args) = extract_args(&spec);
415        // No snap name in the args list for "refresh all".
416        assert_eq!(
417            args.iter()
418                .filter(|a| a.as_str() != "snap" && a.as_str() != "refresh")
419                .count(),
420            0
421        );
422    }
423
424    // ── snap_hold / snap_unhold ───────────────────────────────────────────────
425
426    #[test]
427    fn snap_hold_action_name() {
428        assert_eq!(snap_hold("vim").action_name, "SnapHold");
429    }
430
431    #[test]
432    fn snap_hold_argv_uses_refresh_hold() {
433        let spec = snap_hold("vim");
434        let (_, args) = extract_args(&spec);
435        let a: Vec<&str> = args.iter().map(String::as_str).collect();
436        assert!(a.contains(&"refresh"));
437        assert!(a.contains(&"--hold"));
438        assert!(a.contains(&"vim"));
439    }
440
441    #[test]
442    fn snap_unhold_action_name() {
443        assert_eq!(snap_unhold("vim").action_name, "SnapUnhold");
444    }
445
446    #[test]
447    fn snap_unhold_argv_uses_refresh_unhold() {
448        let spec = snap_unhold("vim");
449        let (_, args) = extract_args(&spec);
450        let a: Vec<&str> = args.iter().map(String::as_str).collect();
451        assert!(a.contains(&"refresh"));
452        assert!(a.contains(&"--unhold"));
453        assert!(a.contains(&"vim"));
454    }
455
456    // ── snap_list ────────────────────────────────────────────────────────────
457
458    #[test]
459    fn snap_list_action_name() {
460        assert_eq!(snap_list().action_name, "SnapList");
461    }
462
463    #[test]
464    fn snap_list_no_sudo() {
465        let spec = snap_list();
466        let (prog, _) = extract_args(&spec);
467        assert_ne!(prog, "sudo");
468    }
469
470    #[test]
471    fn snap_list_risk_low() {
472        assert_eq!(snap_list().risk_level, RiskLevel::Low);
473    }
474
475    // ── snap_info ────────────────────────────────────────────────────────────
476
477    #[test]
478    fn snap_info_action_name() {
479        assert_eq!(snap_info("firefox").action_name, "SnapInfo");
480    }
481
482    #[test]
483    fn snap_info_no_sudo() {
484        let spec = snap_info("firefox");
485        let (prog, _) = extract_args(&spec);
486        assert_ne!(prog, "sudo");
487    }
488
489    #[test]
490    fn snap_info_risk_low() {
491        assert_eq!(snap_info("firefox").risk_level, RiskLevel::Low);
492    }
493
494    #[test]
495    fn snap_info_includes_name() {
496        let spec = snap_info("code");
497        let (_, args) = extract_args(&spec);
498        let a: Vec<&str> = args.iter().map(String::as_str).collect();
499        assert!(a.contains(&"code"));
500    }
501
502    // ── snap_revert ──────────────────────────────────────────────────────────
503
504    #[test]
505    fn snap_revert_action_name() {
506        assert_eq!(snap_revert("firefox").action_name, "SnapRevert");
507    }
508
509    #[test]
510    fn snap_revert_argv_correct() {
511        let spec = snap_revert("firefox");
512        let (prog, args) = extract_args(&spec);
513        assert_eq!(prog, "sudo");
514        let a: Vec<&str> = args.iter().map(String::as_str).collect();
515        assert!(a.contains(&"snap"));
516        assert!(a.contains(&"revert"));
517        assert!(a.contains(&"firefox"));
518    }
519
520    #[test]
521    fn snap_revert_risk_medium() {
522        assert_eq!(snap_revert("firefox").risk_level, RiskLevel::Medium);
523    }
524
525    // ── snap_classic_install ─────────────────────────────────────────────────
526
527    #[test]
528    fn snap_classic_install_action_name() {
529        assert_eq!(
530            snap_classic_install("code").action_name,
531            "SnapClassicInstall"
532        );
533    }
534
535    #[test]
536    fn snap_classic_install_argv_contains_classic_flag() {
537        let spec = snap_classic_install("code");
538        let (prog, args) = extract_args(&spec);
539        assert_eq!(prog, "sudo");
540        let a: Vec<&str> = args.iter().map(String::as_str).collect();
541        assert!(a.contains(&"snap"));
542        assert!(a.contains(&"install"));
543        assert!(a.contains(&"--classic"));
544        assert!(a.contains(&"code"));
545    }
546
547    #[test]
548    fn snap_classic_install_risk_medium() {
549        assert_eq!(snap_classic_install("code").risk_level, RiskLevel::Medium);
550    }
551
552    // ── specs() completeness ─────────────────────────────────────────────────
553
554    #[test]
555    fn specs_covers_all_action_names() {
556        let expected = [
557            "SnapInstall",
558            "SnapRemove",
559            "SnapRefresh",
560            "SnapHold",
561            "SnapUnhold",
562            "SnapList",
563            "SnapInfo",
564            "SnapRevert",
565            "SnapClassicInstall",
566        ];
567        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
568        for name in &expected {
569            assert!(spec_names.contains(name), "specs() missing {name}");
570        }
571    }
572}