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/// Names the PPA as an option value. argparse keeps `--ppa` mutually exclusive
45/// with `--uri`, `--sourceslist` and the positional sources.list line, so the
46/// sudoers grant `add-apt-repository -y --ppa *` cannot be extended into a raw
47/// `deb [trusted=yes] http://...` entry the way the positional form could.
48const PPA_FLAG: &str = "--ppa";
49
50// ---------------------------------------------------------------------------
51// specs() — for action_consistency tests
52// ---------------------------------------------------------------------------
53
54/// Return one representative `ActionSpec` per PPA action name.
55pub fn specs() -> Vec<ActionSpec> {
56    vec![add_ppa("deadsnakes/ppa"), remove_ppa("deadsnakes/ppa")]
57}
58
59// ---------------------------------------------------------------------------
60// Helpers
61// ---------------------------------------------------------------------------
62
63/// Build the full `ppa:<user>/<ppa>` argument string.
64fn ppa_arg(name: &str) -> String {
65    format!("ppa:{name}")
66}
67
68// ---------------------------------------------------------------------------
69// Action constructors
70// ---------------------------------------------------------------------------
71
72/// Add a PPA repository (`sudo add-apt-repository -y --ppa ppa:<user>/<ppa>`).
73///
74/// Risk: High. Adds a third-party apt source, expanding the trusted software
75/// supply chain; manually reversible by running `RemovePpa`. Requires
76/// `software-properties-common` to be installed.
77///
78/// `rollback_available` is false: the daemon has no automatic rollback for
79/// apt/PPA state (see `docs/automatic-rollback.md`); manual reversibility via
80/// `RemovePpa` is a separate fact from that flag.
81///
82/// `name` must be in `<user>/<ppa>` format (e.g. `"deadsnakes/ppa"`).
83/// Validation is enforced in the executor via `validated_ppa_name` before
84/// this constructor is called.
85pub fn add_ppa(name: &str) -> ActionSpec {
86    ActionSpec {
87        action_name: "AddPpa",
88        mechanism: command_mechanism(
89            "sudo",
90            [ADD_APT_REPOSITORY, YES_FLAG, PPA_FLAG, &ppa_arg(name)],
91        ),
92        risk_level: RiskLevel::High,
93        reboot_required: false,
94        rollback_available: false,
95    }
96}
97
98/// Remove a PPA repository
99/// (`sudo add-apt-repository -y --remove --ppa ppa:<user>/<ppa>`).
100///
101/// Risk: Medium. Removes the apt source entry; manually reversible by running
102/// `AddPpa`. Requires `software-properties-common` to be installed.
103///
104/// `rollback_available` is false — see [`add_ppa`]'s doc for why.
105///
106/// `name` must be in `<user>/<ppa>` format (e.g. `"deadsnakes/ppa"`).
107pub fn remove_ppa(name: &str) -> ActionSpec {
108    ActionSpec {
109        action_name: "RemovePpa",
110        mechanism: command_mechanism(
111            "sudo",
112            [
113                ADD_APT_REPOSITORY,
114                YES_FLAG,
115                REMOVE_FLAG,
116                PPA_FLAG,
117                &ppa_arg(name),
118            ],
119        ),
120        risk_level: RiskLevel::Medium,
121        reboot_required: false,
122        rollback_available: false,
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Tests
128// ---------------------------------------------------------------------------
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::actions::ActionMechanism;
134
135    fn extract_args(spec: &ActionSpec) -> (&'static str, Vec<String>) {
136        match &spec.mechanism {
137            ActionMechanism::Command { program, args } => (*program, args.clone()),
138            _ => panic!("expected Command mechanism"),
139        }
140    }
141
142    // ── add_ppa ──────────────────────────────────────────────────────────────
143
144    #[test]
145    fn add_ppa_action_name() {
146        assert_eq!(add_ppa("deadsnakes/ppa").action_name, "AddPpa");
147    }
148
149    #[test]
150    fn add_ppa_argv_correct() {
151        let spec = add_ppa("deadsnakes/ppa");
152        let (prog, args) = extract_args(&spec);
153        assert_eq!(prog, "sudo");
154        let joined = args.join(" ");
155        assert!(
156            joined.contains("add-apt-repository"),
157            "missing add-apt-repository: {joined}"
158        );
159        assert!(joined.contains("-y"), "missing -y: {joined}");
160        assert!(
161            joined.contains("ppa:deadsnakes/ppa"),
162            "missing ppa:deadsnakes/ppa: {joined}"
163        );
164        assert_eq!(
165            args,
166            vec!["add-apt-repository", "-y", "--ppa", "ppa:deadsnakes/ppa"]
167        );
168        // Must NOT include --remove for the add variant.
169        assert!(
170            !joined.contains("--remove"),
171            "unexpected --remove: {joined}"
172        );
173    }
174
175    #[test]
176    fn add_ppa_risk_is_high() {
177        assert_eq!(add_ppa("deadsnakes/ppa").risk_level, RiskLevel::High);
178    }
179
180    #[test]
181    fn add_ppa_no_automatic_rollback() {
182        // apt/PPA state has no automatic-rollback mechanism; only rpm-ostree
183        // deployment actions do (see docs/automatic-rollback.md).
184        assert!(!add_ppa("deadsnakes/ppa").rollback_available);
185    }
186
187    // ── remove_ppa ───────────────────────────────────────────────────────────
188
189    #[test]
190    fn remove_ppa_action_name() {
191        assert_eq!(remove_ppa("deadsnakes/ppa").action_name, "RemovePpa");
192    }
193
194    #[test]
195    fn remove_ppa_argv_contains_remove_flag() {
196        let spec = remove_ppa("deadsnakes/ppa");
197        let (prog, args) = extract_args(&spec);
198        assert_eq!(prog, "sudo");
199        let joined = args.join(" ");
200        assert!(joined.contains("--remove"), "missing --remove: {joined}");
201        assert!(
202            joined.contains("ppa:deadsnakes/ppa"),
203            "missing ppa arg: {joined}"
204        );
205        assert!(joined.contains("-y"), "missing -y: {joined}");
206        assert_eq!(
207            args,
208            vec![
209                "add-apt-repository",
210                "-y",
211                "--remove",
212                "--ppa",
213                "ppa:deadsnakes/ppa"
214            ]
215        );
216    }
217
218    #[test]
219    fn remove_ppa_risk_is_medium() {
220        assert_eq!(remove_ppa("deadsnakes/ppa").risk_level, RiskLevel::Medium);
221    }
222
223    #[test]
224    fn remove_ppa_no_automatic_rollback() {
225        assert!(!remove_ppa("deadsnakes/ppa").rollback_available);
226    }
227
228    // ── ppa_arg ──────────────────────────────────────────────────────────────
229
230    #[test]
231    fn ppa_arg_prepends_ppa_prefix() {
232        assert_eq!(ppa_arg("user/repo"), "ppa:user/repo");
233    }
234
235    // ── specs() completeness ─────────────────────────────────────────────────
236
237    #[test]
238    fn specs_covers_all_action_names() {
239        let expected = ["AddPpa", "RemovePpa"];
240        let spec_names: Vec<&str> = specs().iter().map(|s| s.action_name).collect();
241        for name in &expected {
242            assert!(spec_names.contains(name), "specs() missing {name}");
243        }
244    }
245}