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 hold is applied by building a two-command spec using a shell fragment
14//! via `sh -c "snap install … && snap refresh --hold …"`. `name` and
15//! `channel` are validated by [`snap_install`] itself before interpolation
16//! (in addition to, not instead of, the executor's own `validated_safe_arg`
17//! check) — see the `SnapInstallError` doc below.
18
19use super::{command_mechanism, ActionSpec};
20use sysknife_types::RiskLevel;
21
22// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Returned when `snap_install`'s `name` or `channel` fails validation.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum SnapInstallError {
29    /// `name`/`channel` contains a shell metacharacter or otherwise fails the
30    /// safe-arg allowlist. Carries the offending parameter and value.
31    ///
32    /// Defense in depth: the executor already validates both via
33    /// `validated_safe_arg` before calling this constructor, but the
34    /// `auto_update: false` path interpolates `name`/`channel` into a
35    /// `sh -c "snap install … && snap refresh --hold …"` fragment — a future
36    /// internal Rust caller (fleet plan/execute path) that skipped the
37    /// executor could not otherwise be blocked from injecting through it.
38    InvalidArg { param: &'static str, value: String },
39}
40
41impl std::fmt::Display for SnapInstallError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::InvalidArg { param, value } => write!(f, "invalid {param}: '{value}'"),
45        }
46    }
47}
48
49impl std::error::Error for SnapInstallError {}
50
51/// Allowlist for `name`/`channel`: alphanumeric plus `._:/+@-`, no leading
52/// dash, 1-254 bytes. Mirrors `validated_safe_arg`'s charset exactly, kept as
53/// a self-contained check here (rather than depending on `crate::executor`)
54/// so this action module has no dependency on the executor's error type —
55/// the same pattern `fail2ban::jail_is_valid` uses.
56fn safe_snap_arg_is_valid(s: &str) -> bool {
57    if s.is_empty() || s.len() > 254 || s.starts_with('-') {
58        return false;
59    }
60    s.chars()
61        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-'))
62}
63
64// ---------------------------------------------------------------------------
65// specs() — for action_consistency tests
66// ---------------------------------------------------------------------------
67
68/// Return one representative `ActionSpec` per snap action name.
69pub fn specs() -> Vec<ActionSpec> {
70    vec![
71        snap_install("firefox", None, false).expect("firefox is a valid snap arg"),
72        snap_remove("firefox"),
73        snap_refresh(Some("firefox")),
74        snap_hold("firefox"),
75        snap_unhold("firefox"),
76        snap_list(),
77        snap_info("firefox"),
78        snap_revert("firefox"),
79        snap_classic_install("code"),
80    ]
81}
82
83// ---------------------------------------------------------------------------
84// Action constructors
85// ---------------------------------------------------------------------------
86
87/// Install a snap (`snap install [--channel=<channel>] <name>`) and,
88/// unless `auto_update` is true, immediately hold it to prevent auto-refresh.
89///
90/// Risk: Medium. Installs snaps from the Snap Store. Sandboxed but can access
91/// system resources depending on interface connections.
92///
93/// `channel` defaults to `stable` when `None`.
94///
95/// Returns `Err(SnapInstallError::InvalidArg)` if `name` or `channel` fails
96/// the safe-arg allowlist — see [`SnapInstallError`].
97pub fn snap_install(
98    name: &str,
99    channel: Option<&str>,
100    auto_update: bool,
101) -> Result<ActionSpec, SnapInstallError> {
102    if !safe_snap_arg_is_valid(name) {
103        return Err(SnapInstallError::InvalidArg {
104            param: "name",
105            value: name.to_string(),
106        });
107    }
108    if let Some(ch) = channel {
109        if !safe_snap_arg_is_valid(ch) {
110            return Err(SnapInstallError::InvalidArg {
111                param: "channel",
112                value: ch.to_string(),
113            });
114        }
115    }
116
117    if auto_update {
118        // Plain install without hold.
119        let mut args = vec!["install".to_string()];
120        if let Some(ch) = channel {
121            args.push(format!("--channel={}", ch));
122        }
123        args.push(name.to_string());
124        Ok(ActionSpec {
125            action_name: "SnapInstall",
126            mechanism: command_mechanism(
127                "sudo",
128                std::iter::once("snap").chain(args.iter().map(String::as_str)),
129            ),
130            risk_level: RiskLevel::Medium,
131            reboot_required: false,
132            rollback_available: false,
133        })
134    } else {
135        // Install + hold in one shell fragment to avoid a window where the snap
136        // can be auto-refreshed between install and hold.
137        let channel_arg = channel.unwrap_or("stable");
138        let cmd = format!(
139            "snap install --channel={} {} && snap refresh --hold {}",
140            channel_arg, name, name
141        );
142        Ok(ActionSpec {
143            action_name: "SnapInstall",
144            mechanism: super::command_mechanism("sudo", ["sh", "-c", &cmd]),
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        // When auto_update=false the hold is embedded in a sh -c fragment.
303        let full = args.join(" ");
304        assert!(
305            full.contains("snap install"),
306            "missing 'snap install': {full}"
307        );
308        assert!(
309            full.contains("snap refresh --hold firefox"),
310            "missing hold: {full}"
311        );
312    }
313
314    #[test]
315    fn snap_install_auto_update_no_hold() {
316        let spec = snap_install("firefox", None, true).unwrap();
317        let (prog, args) = extract_args(&spec);
318        assert_eq!(prog, "sudo");
319        let full = args.join(" ");
320        // Must install but must NOT hold.
321        assert!(full.contains("install"), "missing install: {full}");
322        assert!(
323            !full.contains("hold"),
324            "unexpected hold in auto_update=true: {full}"
325        );
326    }
327
328    #[test]
329    fn snap_install_custom_channel() {
330        let spec = snap_install("firefox", Some("beta"), false).unwrap();
331        let (_, args) = extract_args(&spec);
332        let full = args.join(" ");
333        assert!(full.contains("beta"), "channel not present: {full}");
334    }
335
336    #[test]
337    fn snap_install_risk_medium() {
338        assert_eq!(
339            snap_install("vim", None, false).unwrap().risk_level,
340            RiskLevel::Medium
341        );
342    }
343
344    #[test]
345    fn snap_install_rejects_shell_metacharacters_in_name() {
346        let err = snap_install("firefox; rm -rf /", None, false).unwrap_err();
347        assert!(matches!(
348            err,
349            SnapInstallError::InvalidArg { param: "name", .. }
350        ));
351    }
352
353    #[test]
354    fn snap_install_rejects_shell_metacharacters_in_channel() {
355        let err = snap_install("firefox", Some("beta && evil"), false).unwrap_err();
356        assert!(matches!(
357            err,
358            SnapInstallError::InvalidArg {
359                param: "channel",
360                ..
361            }
362        ));
363    }
364
365    #[test]
366    fn snap_install_rejects_leading_dash_name() {
367        // Option-injection guard: mirrors validated_safe_arg's leading-dash rule.
368        let err = snap_install("--classic", None, false).unwrap_err();
369        assert!(matches!(
370            err,
371            SnapInstallError::InvalidArg { param: "name", .. }
372        ));
373    }
374
375    // ── snap_remove ──────────────────────────────────────────────────────────
376
377    #[test]
378    fn snap_remove_action_name() {
379        assert_eq!(snap_remove("vim").action_name, "SnapRemove");
380    }
381
382    #[test]
383    fn snap_remove_argv() {
384        let spec = snap_remove("vim");
385        let (prog, args) = extract_args(&spec);
386        assert_eq!(prog, "sudo");
387        let a: Vec<&str> = args.iter().map(String::as_str).collect();
388        assert!(a.contains(&"snap"));
389        assert!(a.contains(&"remove"));
390        assert!(a.contains(&"vim"));
391    }
392
393    #[test]
394    fn snap_remove_risk_medium() {
395        assert_eq!(snap_remove("vim").risk_level, RiskLevel::Medium);
396    }
397
398    // ── snap_refresh ─────────────────────────────────────────────────────────
399
400    #[test]
401    fn snap_refresh_named() {
402        let spec = snap_refresh(Some("firefox"));
403        let (_, args) = extract_args(&spec);
404        let a: Vec<&str> = args.iter().map(String::as_str).collect();
405        assert!(a.contains(&"firefox"));
406    }
407
408    #[test]
409    fn snap_refresh_all_no_extra_args() {
410        let spec = snap_refresh(None);
411        let (_, args) = extract_args(&spec);
412        // No snap name in the args list for "refresh all".
413        assert_eq!(
414            args.iter()
415                .filter(|a| a.as_str() != "snap" && a.as_str() != "refresh")
416                .count(),
417            0
418        );
419    }
420
421    // ── snap_hold / snap_unhold ───────────────────────────────────────────────
422
423    #[test]
424    fn snap_hold_action_name() {
425        assert_eq!(snap_hold("vim").action_name, "SnapHold");
426    }
427
428    #[test]
429    fn snap_hold_argv_uses_refresh_hold() {
430        let spec = snap_hold("vim");
431        let (_, args) = extract_args(&spec);
432        let a: Vec<&str> = args.iter().map(String::as_str).collect();
433        assert!(a.contains(&"refresh"));
434        assert!(a.contains(&"--hold"));
435        assert!(a.contains(&"vim"));
436    }
437
438    #[test]
439    fn snap_unhold_action_name() {
440        assert_eq!(snap_unhold("vim").action_name, "SnapUnhold");
441    }
442
443    #[test]
444    fn snap_unhold_argv_uses_refresh_unhold() {
445        let spec = snap_unhold("vim");
446        let (_, args) = extract_args(&spec);
447        let a: Vec<&str> = args.iter().map(String::as_str).collect();
448        assert!(a.contains(&"refresh"));
449        assert!(a.contains(&"--unhold"));
450        assert!(a.contains(&"vim"));
451    }
452
453    // ── snap_list ────────────────────────────────────────────────────────────
454
455    #[test]
456    fn snap_list_action_name() {
457        assert_eq!(snap_list().action_name, "SnapList");
458    }
459
460    #[test]
461    fn snap_list_no_sudo() {
462        let spec = snap_list();
463        let (prog, _) = extract_args(&spec);
464        assert_ne!(prog, "sudo");
465    }
466
467    #[test]
468    fn snap_list_risk_low() {
469        assert_eq!(snap_list().risk_level, RiskLevel::Low);
470    }
471
472    // ── snap_info ────────────────────────────────────────────────────────────
473
474    #[test]
475    fn snap_info_action_name() {
476        assert_eq!(snap_info("firefox").action_name, "SnapInfo");
477    }
478
479    #[test]
480    fn snap_info_no_sudo() {
481        let spec = snap_info("firefox");
482        let (prog, _) = extract_args(&spec);
483        assert_ne!(prog, "sudo");
484    }
485
486    #[test]
487    fn snap_info_risk_low() {
488        assert_eq!(snap_info("firefox").risk_level, RiskLevel::Low);
489    }
490
491    #[test]
492    fn snap_info_includes_name() {
493        let spec = snap_info("code");
494        let (_, args) = extract_args(&spec);
495        let a: Vec<&str> = args.iter().map(String::as_str).collect();
496        assert!(a.contains(&"code"));
497    }
498
499    // ── snap_revert ──────────────────────────────────────────────────────────
500
501    #[test]
502    fn snap_revert_action_name() {
503        assert_eq!(snap_revert("firefox").action_name, "SnapRevert");
504    }
505
506    #[test]
507    fn snap_revert_argv_correct() {
508        let spec = snap_revert("firefox");
509        let (prog, args) = extract_args(&spec);
510        assert_eq!(prog, "sudo");
511        let a: Vec<&str> = args.iter().map(String::as_str).collect();
512        assert!(a.contains(&"snap"));
513        assert!(a.contains(&"revert"));
514        assert!(a.contains(&"firefox"));
515    }
516
517    #[test]
518    fn snap_revert_risk_medium() {
519        assert_eq!(snap_revert("firefox").risk_level, RiskLevel::Medium);
520    }
521
522    // ── snap_classic_install ─────────────────────────────────────────────────
523
524    #[test]
525    fn snap_classic_install_action_name() {
526        assert_eq!(
527            snap_classic_install("code").action_name,
528            "SnapClassicInstall"
529        );
530    }
531
532    #[test]
533    fn snap_classic_install_argv_contains_classic_flag() {
534        let spec = snap_classic_install("code");
535        let (prog, args) = extract_args(&spec);
536        assert_eq!(prog, "sudo");
537        let a: Vec<&str> = args.iter().map(String::as_str).collect();
538        assert!(a.contains(&"snap"));
539        assert!(a.contains(&"install"));
540        assert!(a.contains(&"--classic"));
541        assert!(a.contains(&"code"));
542    }
543
544    #[test]
545    fn snap_classic_install_risk_medium() {
546        assert_eq!(snap_classic_install("code").risk_level, RiskLevel::Medium);
547    }
548
549    // ── specs() completeness ─────────────────────────────────────────────────
550
551    #[test]
552    fn specs_covers_all_action_names() {
553        let expected = [
554            "SnapInstall",
555            "SnapRemove",
556            "SnapRefresh",
557            "SnapHold",
558            "SnapUnhold",
559            "SnapList",
560            "SnapInfo",
561            "SnapRevert",
562            "SnapClassicInstall",
563        ];
564        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
565        for name in &expected {
566            assert!(spec_names.contains(name), "specs() missing {name}");
567        }
568    }
569}