1use thiserror::Error;
3
4pub type Result<T> = std::result::Result<T, FezError>;
6
7#[derive(Debug, Error)]
9pub enum FezError {
10 #[error("failed to spawn {program}: {source}")]
12 Spawn {
13 program: String,
15 #[source]
17 source: std::io::Error,
18 },
19 #[error("i/o error: {0}")]
21 Io(#[source] std::io::Error),
22 #[error("protocol decode error: {0}")]
24 Decode(#[source] serde_json::Error),
25 #[error("timed out waiting for the bridge")]
27 Timeout,
28 #[error("bridge connection closed")]
30 BridgeClosed,
31 #[error("channel problem: {0}")]
33 Problem(String),
34 #[error("dbus error {name}: {message}")]
36 Dbus {
37 name: String,
39 message: String,
41 },
42 #[error("not found: {0}")]
44 NotFound(String),
45 #[error("refused: {unit} is a protected unit (use --force to override)")]
47 Protected {
48 unit: String,
50 },
51 #[error("missing dependency {component} on target: {remediation}")]
53 DependencyMissing {
54 component: String,
56 dbus_name: String,
58 remediation: String,
60 },
61 #[error("refused: dangerous transaction ({reason}); use --force to override")]
63 DangerousTransaction {
64 reason: String,
66 removed: Vec<String>,
68 },
69 #[error("aborted by user")]
71 Aborted,
72 #[error("access denied: {remediation}")]
75 AccessDenied {
76 remediation: String,
78 },
79}
80
81pub struct ExitCodeDoc {
83 pub code: i32,
85 pub label: &'static str,
87 pub meaning: &'static str,
89}
90
91pub const EXIT_CODES: &[ExitCodeDoc] = &[
96 ExitCodeDoc {
97 code: 1,
98 label: "general",
99 meaning: "Unclassified failure (I/O, decode, aborted).",
100 },
101 ExitCodeDoc {
102 code: 4,
103 label: "not-found",
104 meaning: "Target resource (e.g. a unit) does not exist.",
105 },
106 ExitCodeDoc {
107 code: 5,
108 label: "timeout",
109 meaning: "The bridge did not respond before the deadline.",
110 },
111 ExitCodeDoc {
112 code: 6,
113 label: "bridge",
114 meaning: "Bridge could not be spawned or the connection closed.",
115 },
116 ExitCodeDoc {
117 code: 7,
118 label: "dbus",
119 meaning: "A D-Bus call returned an error.",
120 },
121 ExitCodeDoc {
122 code: 8,
123 label: "protected-unit",
124 meaning: "Protected unit refused without --force.",
125 },
126 ExitCodeDoc {
127 code: 9,
128 label: "dependency-missing",
129 meaning: "Required target dependency (dnf5daemon) is absent or not activatable.",
130 },
131 ExitCodeDoc {
132 code: 10,
133 label: "dangerous-transaction",
134 meaning: "Resolved transaction refused by guardrails (protected package or cascade) without --force.",
135 },
136 ExitCodeDoc {
137 code: 11,
138 label: "access-denied",
139 meaning: "Privilege escalation to root failed (e.g. sudo requires a password fez does not supply).",
140 },
141];
142
143impl FezError {
144 pub fn code(&self) -> &'static str {
146 match self {
147 FezError::Spawn { .. } => "bridge-unavailable",
148 FezError::Io(_) => "io-error",
149 FezError::Decode(_) => "protocol-error",
150 FezError::Timeout => "timeout",
151 FezError::BridgeClosed => "bridge-closed",
152 FezError::Problem(p) => problem_code(p),
153 FezError::Dbus { .. } => "dbus-error",
154 FezError::NotFound(_) => "not-found",
155 FezError::Protected { .. } => "protected-unit",
156 FezError::DependencyMissing { .. } => "dependency-missing",
157 FezError::DangerousTransaction { .. } => "dangerous-transaction",
158 FezError::Aborted => "aborted",
159 FezError::AccessDenied { .. } => "access-denied",
160 }
161 }
162 pub fn exit_code(&self) -> i32 {
164 match self {
165 FezError::NotFound(_) | FezError::Problem(_) => 4,
166 FezError::Timeout => 5,
167 FezError::Spawn { .. } | FezError::BridgeClosed => 6,
168 FezError::Dbus { .. } => 7,
169 FezError::Protected { .. } => 8,
170 FezError::DependencyMissing { .. } => 9,
171 FezError::DangerousTransaction { .. } => 10,
172 FezError::AccessDenied { .. } => 11,
173 _ => 1,
174 }
175 }
176}
177
178fn problem_code(p: &str) -> &'static str {
179 match p {
180 "not-found" => "not-found",
181 "access-denied" => "access-denied",
182 "authentication-failed" => "auth-failed",
183 "not-supported" => "not-supported",
184 _ => "channel-problem",
185 }
186}
187
188pub fn is_service_unknown(name: &str) -> bool {
191 name.contains("ServiceUnknown") || name.contains("NameHasNoOwner")
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn exit_code_table_documents_every_nonone_code() {
200 use std::collections::HashSet;
201 let documented: HashSet<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
202 let produced = [
203 FezError::NotFound("x".into()).exit_code(),
204 FezError::Timeout.exit_code(),
205 FezError::BridgeClosed.exit_code(),
206 FezError::Dbus {
207 name: "n".into(),
208 message: "m".into(),
209 }
210 .exit_code(),
211 FezError::Protected { unit: "u".into() }.exit_code(),
212 FezError::DependencyMissing {
213 component: "c".into(),
214 dbus_name: "n".into(),
215 remediation: "r".into(),
216 }
217 .exit_code(),
218 FezError::DangerousTransaction {
219 reason: "r".into(),
220 removed: vec![],
221 }
222 .exit_code(),
223 FezError::AccessDenied {
224 remediation: "r".into(),
225 }
226 .exit_code(),
227 ];
228 for code in produced {
229 if code != 1 {
230 assert!(
231 documented.contains(&code),
232 "exit code {code} undocumented in EXIT_CODES"
233 );
234 }
235 }
236 }
237
238 #[test]
239 fn exit_code_table_is_nonempty_and_sorted() {
240 assert!(!EXIT_CODES.is_empty());
241 let codes: Vec<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
242 let mut sorted = codes.clone();
243 sorted.sort_unstable();
244 assert_eq!(codes, sorted, "EXIT_CODES should be ascending by code");
245 }
246
247 #[test]
248 fn maps_problem_to_code() {
249 assert_eq!(FezError::Problem("not-found".into()).code(), "not-found");
250 assert_eq!(FezError::Problem("weird".into()).code(), "channel-problem");
251 }
252
253 #[test]
254 fn maps_exit_codes() {
255 assert_eq!(FezError::NotFound("x".into()).exit_code(), 4);
256 assert_eq!(FezError::Timeout.exit_code(), 5);
257 assert_eq!(FezError::BridgeClosed.exit_code(), 6);
258 }
259
260 #[test]
261 fn protected_maps_code_and_exit() {
262 let e = FezError::Protected {
263 unit: "sshd.service".into(),
264 };
265 assert_eq!(e.code(), "protected-unit");
266 assert_eq!(e.exit_code(), 8);
267 }
268
269 #[test]
270 fn dependency_missing_maps_code_and_exit() {
271 let e = FezError::DependencyMissing {
272 component: "dnf5daemon".into(),
273 dbus_name: "org.rpm.dnf.v0".into(),
274 remediation: "install it".into(),
275 };
276 assert_eq!(e.code(), "dependency-missing");
277 assert_eq!(e.exit_code(), 9);
278 }
279
280 #[test]
281 fn dangerous_transaction_maps_code_and_exit() {
282 let e = FezError::DangerousTransaction {
283 reason: "removes protected package glibc".into(),
284 removed: vec!["glibc".into()],
285 };
286 assert_eq!(e.code(), "dangerous-transaction");
287 assert_eq!(e.exit_code(), 10);
288 }
289
290 #[test]
291 fn is_service_unknown_detects_activation_failure() {
292 assert!(is_service_unknown(
293 "org.freedesktop.DBus.Error.ServiceUnknown"
294 ));
295 assert!(is_service_unknown(
296 "org.freedesktop.DBus.Error.NameHasNoOwner"
297 ));
298 assert!(!is_service_unknown("org.freedesktop.systemd1.NoSuchUnit"));
299 }
300
301 #[test]
302 fn aborted_maps_code_and_exit() {
303 assert_eq!(FezError::Aborted.code(), "aborted");
304 assert_eq!(FezError::Aborted.exit_code(), 1);
305 }
306
307 #[test]
308 fn access_denied_maps_code_and_exit() {
309 let e = FezError::AccessDenied {
310 remediation: "configure NOPASSWD sudo".into(),
311 };
312 assert_eq!(e.code(), "access-denied");
313 assert_eq!(e.exit_code(), 11);
314 assert!(e.to_string().contains("configure NOPASSWD sudo"));
315 }
316
317 #[test]
318 fn problem_code_covers_all_known_kinds() {
319 assert_eq!(
320 FezError::Problem("access-denied".into()).code(),
321 "access-denied"
322 );
323 assert_eq!(
324 FezError::Problem("authentication-failed".into()).code(),
325 "auth-failed"
326 );
327 assert_eq!(
328 FezError::Problem("not-supported".into()).code(),
329 "not-supported"
330 );
331 }
332
333 #[test]
334 fn codes_for_spawn_io_decode_dbus() {
335 let spawn = FezError::Spawn {
336 program: "cockpit-bridge".into(),
337 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
338 };
339 assert_eq!(spawn.code(), "bridge-unavailable");
340 assert_eq!(spawn.exit_code(), 6);
341
342 let io = FezError::Io(std::io::Error::other("boom"));
343 assert_eq!(io.code(), "io-error");
344 assert_eq!(io.exit_code(), 1);
345
346 let decode = FezError::Decode(serde_json::from_str::<i32>("nope").unwrap_err());
347 assert_eq!(decode.code(), "protocol-error");
348
349 let dbus = FezError::Dbus {
350 name: "org.example.Err".into(),
351 message: "bad".into(),
352 };
353 assert_eq!(dbus.code(), "dbus-error");
354 assert_eq!(dbus.exit_code(), 7);
355 }
356
357 #[test]
358 fn display_renders_messages() {
359 assert_eq!(
360 FezError::Timeout.to_string(),
361 "timed out waiting for the bridge"
362 );
363 assert_eq!(
364 FezError::BridgeClosed.to_string(),
365 "bridge connection closed"
366 );
367 assert_eq!(FezError::Aborted.to_string(), "aborted by user");
368 assert_eq!(
369 FezError::NotFound("sshd.service".into()).to_string(),
370 "not found: sshd.service"
371 );
372 assert_eq!(
373 FezError::Protected {
374 unit: "sshd.service".into(),
375 }
376 .to_string(),
377 "refused: sshd.service is a protected unit (use --force to override)"
378 );
379 assert_eq!(
380 FezError::Problem("not-found".into()).to_string(),
381 "channel problem: not-found"
382 );
383 assert_eq!(
384 FezError::Dbus {
385 name: "org.example.Err".into(),
386 message: "bad".into(),
387 }
388 .to_string(),
389 "dbus error org.example.Err: bad"
390 );
391 assert_eq!(
392 FezError::Spawn {
393 program: "p".into(),
394 source: std::io::Error::other("x"),
395 }
396 .to_string(),
397 "failed to spawn p: x"
398 );
399 assert!(FezError::Io(std::io::Error::other("disk"))
400 .to_string()
401 .starts_with("i/o error"));
402 assert!(
403 FezError::Decode(serde_json::from_str::<i32>("x").unwrap_err())
404 .to_string()
405 .starts_with("protocol decode error")
406 );
407 assert_eq!(
408 FezError::DependencyMissing {
409 component: "dnf5daemon".into(),
410 dbus_name: "org.rpm.dnf.v0".into(),
411 remediation: "install it".into(),
412 }
413 .to_string(),
414 "missing dependency dnf5daemon on target: install it"
415 );
416 assert_eq!(
417 FezError::DangerousTransaction {
418 reason: "removes glibc".into(),
419 removed: vec!["glibc".into()],
420 }
421 .to_string(),
422 "refused: dangerous transaction (removes glibc); use --force to override"
423 );
424 }
425}