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}