Skip to main content

sysknife_daemon/actions/
ppa.rs

1//! PPA (Personal Package Archive) management actions (Ubuntu).
2//!
3//! Both actions call `add-apt-repository` from the `software-properties-common`
4//! package.  If that package is absent the daemon process will fail with
5//! `ENOENT`; the executor surfaces that as an `ExecutionFailure` with the
6//! binary name in the error message, giving the operator a clear hint.
7//!
8//! ## PPA name validation
9//!
10//! A PPA identifier must follow the `<user>/<ppa>` format where both
11//! components consist of alphanumeric characters, hyphens, underscores, or
12//! dots.  The validator rejects names containing shell-special characters and
13//! names that do not contain exactly one `/`.
14//!
15//! ## Rollback availability
16//!
17//! `rollback_available` means the daemon automatically reverts the action on
18//! failure (`executor::rollback_spec_for` returns `Some` and the dispatcher
19//! runs it as part of the same job — see `docs/automatic-rollback.md`). That
20//! mechanism exists only for rpm-ostree deployment actions today, so both
21//! `AddPpa` and `RemovePpa` are `rollback_available = false`: apt/PPA state is
22//! mutated directly on the live filesystem and the daemon does not stage or
23//! auto-revert it. `AddPpa` and `RemovePpa` remain each other's manual
24//! inverse — an operator (or agent) can undo one by explicitly running the
25//! other — but that is a distinct fact from automatic rollback and is not
26//! what this flag communicates.
27
28use super::{command_mechanism, ActionSpec};
29use sysknife_types::RiskLevel;
30
31// ---------------------------------------------------------------------------
32// Constants
33// ---------------------------------------------------------------------------
34
35/// Binary provided by `software-properties-common`.
36const ADD_APT_REPOSITORY: &str = "add-apt-repository";
37
38/// Flag for non-interactive execution (no "press ENTER to continue" prompt).
39const YES_FLAG: &str = "-y";
40
41/// `add-apt-repository` flag used to remove rather than add a PPA.
42const REMOVE_FLAG: &str = "--remove";
43
44// ---------------------------------------------------------------------------
45// specs() — for action_consistency tests
46// ---------------------------------------------------------------------------
47
48/// Return one representative `ActionSpec` per PPA action name.
49pub fn specs() -> Vec<ActionSpec> {
50    vec![add_ppa("deadsnakes/ppa"), remove_ppa("deadsnakes/ppa")]
51}
52
53// ---------------------------------------------------------------------------
54// Helpers
55// ---------------------------------------------------------------------------
56
57/// Build the full `ppa:<user>/<ppa>` argument string.
58fn ppa_arg(name: &str) -> String {
59    format!("ppa:{name}")
60}
61
62// ---------------------------------------------------------------------------
63// Action constructors
64// ---------------------------------------------------------------------------
65
66/// Add a PPA repository (`sudo add-apt-repository -y ppa:<user>/<ppa>`).
67///
68/// Risk: High. Adds a third-party apt source, expanding the trusted software
69/// supply chain; manually reversible by running `RemovePpa`. Requires
70/// `software-properties-common` to be installed.
71///
72/// `rollback_available` is false: the daemon has no automatic rollback for
73/// apt/PPA state (see `docs/automatic-rollback.md`); manual reversibility via
74/// `RemovePpa` is a separate fact from that flag.
75///
76/// `name` must be in `<user>/<ppa>` format (e.g. `"deadsnakes/ppa"`).
77/// Validation is enforced in the executor via `validated_ppa_name` before
78/// this constructor is called.
79pub fn add_ppa(name: &str) -> ActionSpec {
80    ActionSpec {
81        action_name: "AddPpa",
82        mechanism: command_mechanism("sudo", [ADD_APT_REPOSITORY, YES_FLAG, &ppa_arg(name)]),
83        risk_level: RiskLevel::High,
84        reboot_required: false,
85        rollback_available: false,
86    }
87}
88
89/// Remove a PPA repository (`sudo add-apt-repository -y --remove ppa:<user>/<ppa>`).
90///
91/// Risk: Medium. Removes the apt source entry; manually reversible by running
92/// `AddPpa`. Requires `software-properties-common` to be installed.
93///
94/// `rollback_available` is false — see [`add_ppa`]'s doc for why.
95///
96/// `name` must be in `<user>/<ppa>` format (e.g. `"deadsnakes/ppa"`).
97pub fn remove_ppa(name: &str) -> ActionSpec {
98    ActionSpec {
99        action_name: "RemovePpa",
100        mechanism: command_mechanism(
101            "sudo",
102            [ADD_APT_REPOSITORY, YES_FLAG, REMOVE_FLAG, &ppa_arg(name)],
103        ),
104        risk_level: RiskLevel::Medium,
105        reboot_required: false,
106        rollback_available: false,
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Tests
112// ---------------------------------------------------------------------------
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::actions::ActionMechanism;
118
119    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
120        match &spec.mechanism {
121            ActionMechanism::Command { program, args } => (*program, args.clone()),
122            _ => panic!("expected Command mechanism"),
123        }
124    }
125
126    // ── add_ppa ──────────────────────────────────────────────────────────────
127
128    #[test]
129    fn add_ppa_action_name() {
130        assert_eq!(add_ppa("deadsnakes/ppa").action_name, "AddPpa");
131    }
132
133    #[test]
134    fn add_ppa_argv_correct() {
135        let spec = add_ppa("deadsnakes/ppa");
136        let (prog, args) = extract_args(&spec);
137        assert_eq!(prog, "sudo");
138        let joined = args.join(" ");
139        assert!(
140            joined.contains("add-apt-repository"),
141            "missing add-apt-repository: {joined}"
142        );
143        assert!(joined.contains("-y"), "missing -y: {joined}");
144        assert!(
145            joined.contains("ppa:deadsnakes/ppa"),
146            "missing ppa:deadsnakes/ppa: {joined}"
147        );
148        // Must NOT include --remove for the add variant.
149        assert!(
150            !joined.contains("--remove"),
151            "unexpected --remove: {joined}"
152        );
153    }
154
155    #[test]
156    fn add_ppa_risk_is_high() {
157        assert_eq!(add_ppa("deadsnakes/ppa").risk_level, RiskLevel::High);
158    }
159
160    #[test]
161    fn add_ppa_no_automatic_rollback() {
162        // apt/PPA state has no automatic-rollback mechanism; only rpm-ostree
163        // deployment actions do (see docs/automatic-rollback.md).
164        assert!(!add_ppa("deadsnakes/ppa").rollback_available);
165    }
166
167    // ── remove_ppa ───────────────────────────────────────────────────────────
168
169    #[test]
170    fn remove_ppa_action_name() {
171        assert_eq!(remove_ppa("deadsnakes/ppa").action_name, "RemovePpa");
172    }
173
174    #[test]
175    fn remove_ppa_argv_contains_remove_flag() {
176        let spec = remove_ppa("deadsnakes/ppa");
177        let (prog, args) = extract_args(&spec);
178        assert_eq!(prog, "sudo");
179        let joined = args.join(" ");
180        assert!(joined.contains("--remove"), "missing --remove: {joined}");
181        assert!(
182            joined.contains("ppa:deadsnakes/ppa"),
183            "missing ppa arg: {joined}"
184        );
185        assert!(joined.contains("-y"), "missing -y: {joined}");
186    }
187
188    #[test]
189    fn remove_ppa_risk_is_medium() {
190        assert_eq!(remove_ppa("deadsnakes/ppa").risk_level, RiskLevel::Medium);
191    }
192
193    #[test]
194    fn remove_ppa_no_automatic_rollback() {
195        assert!(!remove_ppa("deadsnakes/ppa").rollback_available);
196    }
197
198    // ── ppa_arg ──────────────────────────────────────────────────────────────
199
200    #[test]
201    fn ppa_arg_prepends_ppa_prefix() {
202        assert_eq!(ppa_arg("user/repo"), "ppa:user/repo");
203    }
204
205    // ── specs() completeness ─────────────────────────────────────────────────
206
207    #[test]
208    fn specs_covers_all_action_names() {
209        let expected = ["AddPpa", "RemovePpa"];
210        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
211        for name in &expected {
212            assert!(spec_names.contains(name), "specs() missing {name}");
213        }
214    }
215}