1use serde_json::{json, Value};
3use thiserror::Error;
4
5pub type Result<T> = std::result::Result<T, FezError>;
7
8#[derive(Debug, Error)]
10pub enum FezError {
11 #[error("failed to spawn {program}: {source}")]
13 Spawn {
14 program: String,
16 #[source]
18 source: std::io::Error,
19 },
20 #[error("i/o error: {0}")]
22 Io(#[source] std::io::Error),
23 #[error("protocol decode error: {0}")]
25 Decode(#[source] serde_json::Error),
26 #[error("timed out waiting for the bridge")]
28 Timeout,
29 #[error("bridge connection closed")]
31 BridgeClosed,
32 #[error("channel problem: {0}")]
34 Problem(String),
35 #[error("dbus error {name}: {message}")]
37 Dbus {
38 name: String,
40 message: String,
42 },
43 #[error("not found: {0}")]
45 NotFound(String),
46 #[error("refused: {unit} is a protected unit (use --force to override)")]
48 Protected {
49 unit: String,
51 },
52 #[error("missing dependency {component} on target: {remediation}")]
54 DependencyMissing {
55 component: String,
57 dbus_name: String,
59 remediation: String,
61 },
62 #[error("refused: dangerous transaction ({reason}); use --force to override")]
64 DangerousTransaction {
65 reason: String,
67 removed: Vec<String>,
69 },
70 #[error("usage error: {0}")]
74 Usage(String),
75 #[error("aborted by user")]
77 Aborted,
78 #[error("access denied: {remediation}")]
81 AccessDenied {
82 remediation: String,
84 },
85 #[error("unsupported API: {0} is not available on the target")]
90 UnsupportedApi(String),
91}
92
93pub struct ExitCodeDoc {
95 pub code: i32,
97 pub label: &'static str,
99 pub meaning: &'static str,
101}
102
103pub const EXIT_CODES: &[ExitCodeDoc] = &[
108 ExitCodeDoc {
109 code: 1,
110 label: "general",
111 meaning: "Unclassified failure (I/O, decode, aborted).",
112 },
113 ExitCodeDoc {
114 code: 2,
115 label: "usage",
116 meaning: "CLI usage error (missing/invalid argument or unknown flag).",
117 },
118 ExitCodeDoc {
119 code: 4,
120 label: "not-found",
121 meaning: "Target resource (e.g. a unit) does not exist.",
122 },
123 ExitCodeDoc {
124 code: 5,
125 label: "timeout",
126 meaning: "The bridge did not respond before the deadline.",
127 },
128 ExitCodeDoc {
129 code: 6,
130 label: "bridge",
131 meaning: "Bridge could not be spawned or the connection closed.",
132 },
133 ExitCodeDoc {
134 code: 7,
135 label: "dbus",
136 meaning: "A D-Bus call returned an error.",
137 },
138 ExitCodeDoc {
139 code: 8,
140 label: "protected-unit",
141 meaning: "Protected unit refused without --force.",
142 },
143 ExitCodeDoc {
144 code: 9,
145 label: "dependency-missing",
146 meaning: "Required target dependency (dnf5daemon) is absent or not activatable.",
147 },
148 ExitCodeDoc {
149 code: 10,
150 label: "dangerous-transaction",
151 meaning: "Resolved transaction refused by guardrails (protected package or cascade) without --force.",
152 },
153 ExitCodeDoc {
154 code: 11,
155 label: "access-denied",
156 meaning: "Privilege escalation to root failed (e.g. sudo requires a password fez does not supply).",
157 },
158 ExitCodeDoc {
159 code: 12,
160 label: "unsupported-api",
161 meaning: "The managed subsystem is reachable but lacks a required D-Bus method (API too old).",
162 },
163];
164
165impl FezError {
166 pub fn code(&self) -> &'static str {
168 match self {
169 FezError::Spawn { .. } => "bridge-unavailable",
170 FezError::Io(_) => "io-error",
171 FezError::Decode(_) => "protocol-error",
172 FezError::Timeout => "timeout",
173 FezError::BridgeClosed => "bridge-closed",
174 FezError::Problem(p) => problem_code(p),
175 FezError::Dbus { .. } => "dbus-error",
176 FezError::NotFound(_) => "not-found",
177 FezError::Protected { .. } => "protected-unit",
178 FezError::DependencyMissing { .. } => "dependency-missing",
179 FezError::DangerousTransaction { .. } => "dangerous-transaction",
180 FezError::Usage(_) => "usage",
181 FezError::Aborted => "aborted",
182 FezError::AccessDenied { .. } => "access-denied",
183 FezError::UnsupportedApi(_) => "unsupported-api",
184 }
185 }
186 pub fn exit_code(&self) -> i32 {
188 match self {
189 FezError::Usage(_) => 2,
192 FezError::NotFound(_) | FezError::Problem(_) => 4,
193 FezError::Timeout => 5,
194 FezError::Spawn { .. } | FezError::BridgeClosed => 6,
195 FezError::Dbus { .. } => 7,
196 FezError::Protected { .. } => 8,
197 FezError::DependencyMissing { .. } => 9,
198 FezError::DangerousTransaction { .. } => 10,
199 FezError::AccessDenied { .. } => 11,
200 FezError::UnsupportedApi(_) => 12,
201 _ => 1,
202 }
203 }
204 pub fn detail(&self) -> Option<Value> {
212 match self {
213 FezError::DependencyMissing {
214 component,
215 dbus_name,
216 remediation,
217 } => Some(json!({
218 "component": component,
219 "dbusName": dbus_name,
220 "remediation": remediation,
221 })),
222 FezError::DangerousTransaction { reason, removed } => Some(json!({
223 "reason": reason,
224 "removed": removed,
225 })),
226 FezError::UnsupportedApi(method) => Some(json!({ "method": method })),
227 _ => None,
228 }
229 }
230}
231
232fn problem_code(p: &str) -> &'static str {
233 match p {
234 "not-found" => "not-found",
235 "access-denied" => "access-denied",
236 "authentication-failed" => "auth-failed",
237 "not-supported" => "not-supported",
238 _ => "channel-problem",
239 }
240}
241
242pub fn is_service_unknown(name: &str) -> bool {
245 name.contains("ServiceUnknown") || name.contains("NameHasNoOwner")
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn exit_code_table_documents_every_nonone_code() {
254 use std::collections::HashSet;
255 let documented: HashSet<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
256 let produced = [
257 FezError::NotFound("x".into()).exit_code(),
258 FezError::Timeout.exit_code(),
259 FezError::BridgeClosed.exit_code(),
260 FezError::Dbus {
261 name: "n".into(),
262 message: "m".into(),
263 }
264 .exit_code(),
265 FezError::Protected { unit: "u".into() }.exit_code(),
266 FezError::DependencyMissing {
267 component: "c".into(),
268 dbus_name: "n".into(),
269 remediation: "r".into(),
270 }
271 .exit_code(),
272 FezError::DangerousTransaction {
273 reason: "r".into(),
274 removed: vec![],
275 }
276 .exit_code(),
277 FezError::AccessDenied {
278 remediation: "r".into(),
279 }
280 .exit_code(),
281 FezError::Usage("missing <UNIT>".into()).exit_code(),
282 FezError::UnsupportedApi("getMasquerade".into()).exit_code(),
283 ];
284 for code in produced {
285 if code != 1 {
286 assert!(
287 documented.contains(&code),
288 "exit code {code} undocumented in EXIT_CODES"
289 );
290 }
291 }
292 }
293
294 #[test]
295 fn exit_code_table_is_nonempty_and_sorted() {
296 assert!(!EXIT_CODES.is_empty());
297 let codes: Vec<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
298 let mut sorted = codes.clone();
299 sorted.sort_unstable();
300 assert_eq!(codes, sorted, "EXIT_CODES should be ascending by code");
301 }
302
303 #[test]
304 fn maps_problem_to_code() {
305 assert_eq!(FezError::Problem("not-found".into()).code(), "not-found");
306 assert_eq!(FezError::Problem("weird".into()).code(), "channel-problem");
307 }
308
309 #[test]
310 fn maps_exit_codes() {
311 assert_eq!(FezError::NotFound("x".into()).exit_code(), 4);
312 assert_eq!(FezError::Timeout.exit_code(), 5);
313 assert_eq!(FezError::BridgeClosed.exit_code(), 6);
314 }
315
316 #[test]
317 fn protected_maps_code_and_exit() {
318 let e = FezError::Protected {
319 unit: "sshd.service".into(),
320 };
321 assert_eq!(e.code(), "protected-unit");
322 assert_eq!(e.exit_code(), 8);
323 }
324
325 #[test]
326 fn dependency_missing_maps_code_and_exit() {
327 let e = FezError::DependencyMissing {
328 component: "dnf5daemon".into(),
329 dbus_name: "org.rpm.dnf.v0".into(),
330 remediation: "install it".into(),
331 };
332 assert_eq!(e.code(), "dependency-missing");
333 assert_eq!(e.exit_code(), 9);
334 }
335
336 #[test]
337 fn dangerous_transaction_maps_code_and_exit() {
338 let e = FezError::DangerousTransaction {
339 reason: "removes protected package glibc".into(),
340 removed: vec!["glibc".into()],
341 };
342 assert_eq!(e.code(), "dangerous-transaction");
343 assert_eq!(e.exit_code(), 10);
344 }
345
346 #[test]
347 fn is_service_unknown_detects_activation_failure() {
348 assert!(is_service_unknown(
349 "org.freedesktop.DBus.Error.ServiceUnknown"
350 ));
351 assert!(is_service_unknown(
352 "org.freedesktop.DBus.Error.NameHasNoOwner"
353 ));
354 assert!(!is_service_unknown("org.freedesktop.systemd1.NoSuchUnit"));
355 }
356
357 #[test]
358 fn aborted_maps_code_and_exit() {
359 assert_eq!(FezError::Aborted.code(), "aborted");
360 assert_eq!(FezError::Aborted.exit_code(), 1);
361 }
362
363 #[test]
364 fn usage_maps_code_and_exit() {
365 let e = FezError::Usage("missing required argument: <UNIT>".into());
366 assert_eq!(e.code(), "usage");
367 assert_eq!(e.exit_code(), 2);
368 assert!(e.to_string().contains("missing required argument"));
369 }
370
371 #[test]
372 fn unsupported_api_maps_code_and_exit() {
373 let e = FezError::UnsupportedApi("getMasquerade".into());
374 assert_eq!(e.code(), "unsupported-api");
375 assert_eq!(e.exit_code(), 12);
376 assert!(e.to_string().contains("getMasquerade"));
377 }
378
379 #[test]
380 fn detail_carries_dependency_missing_fields() {
381 let e = FezError::DependencyMissing {
382 component: "dnf5daemon".into(),
383 dbus_name: "org.rpm.dnf.v0".into(),
384 remediation: "install it".into(),
385 };
386 let d = e.detail().expect("dependency-missing has detail");
387 assert_eq!(d["component"], "dnf5daemon");
388 assert_eq!(d["dbusName"], "org.rpm.dnf.v0");
389 assert_eq!(d["remediation"], "install it");
390 }
391
392 #[test]
393 fn detail_carries_dangerous_transaction_fields() {
394 let e = FezError::DangerousTransaction {
395 reason: "removes glibc".into(),
396 removed: vec!["glibc".into()],
397 };
398 let d = e.detail().expect("dangerous-transaction has detail");
399 assert_eq!(d["reason"], "removes glibc");
400 assert_eq!(d["removed"], json!(["glibc"]));
401 }
402
403 #[test]
404 fn detail_carries_unsupported_api_method() {
405 let e = FezError::UnsupportedApi("getMasquerade".into());
406 let d = e.detail().expect("unsupported-api has detail");
407 assert_eq!(d["method"], "getMasquerade");
408 }
409
410 #[test]
411 fn detail_is_none_for_variants_without_structured_payload() {
412 assert!(FezError::Timeout.detail().is_none());
413 assert!(FezError::NotFound("x".into()).detail().is_none());
414 assert!(FezError::Protected { unit: "u".into() }.detail().is_none());
415 assert!(FezError::AccessDenied {
416 remediation: "r".into()
417 }
418 .detail()
419 .is_none());
420 }
421
422 #[test]
423 fn access_denied_maps_code_and_exit() {
424 let e = FezError::AccessDenied {
425 remediation: "configure NOPASSWD sudo".into(),
426 };
427 assert_eq!(e.code(), "access-denied");
428 assert_eq!(e.exit_code(), 11);
429 assert!(e.to_string().contains("configure NOPASSWD sudo"));
430 }
431
432 #[test]
433 fn problem_code_covers_all_known_kinds() {
434 assert_eq!(
435 FezError::Problem("access-denied".into()).code(),
436 "access-denied"
437 );
438 assert_eq!(
439 FezError::Problem("authentication-failed".into()).code(),
440 "auth-failed"
441 );
442 assert_eq!(
443 FezError::Problem("not-supported".into()).code(),
444 "not-supported"
445 );
446 }
447
448 #[test]
449 fn codes_for_spawn_io_decode_dbus() {
450 let spawn = FezError::Spawn {
451 program: "cockpit-bridge".into(),
452 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
453 };
454 assert_eq!(spawn.code(), "bridge-unavailable");
455 assert_eq!(spawn.exit_code(), 6);
456
457 let io = FezError::Io(std::io::Error::other("boom"));
458 assert_eq!(io.code(), "io-error");
459 assert_eq!(io.exit_code(), 1);
460
461 let decode = FezError::Decode(serde_json::from_str::<i32>("nope").unwrap_err());
462 assert_eq!(decode.code(), "protocol-error");
463
464 let dbus = FezError::Dbus {
465 name: "org.example.Err".into(),
466 message: "bad".into(),
467 };
468 assert_eq!(dbus.code(), "dbus-error");
469 assert_eq!(dbus.exit_code(), 7);
470 }
471
472 #[test]
473 fn display_renders_messages() {
474 assert_eq!(
475 FezError::Timeout.to_string(),
476 "timed out waiting for the bridge"
477 );
478 assert_eq!(
479 FezError::BridgeClosed.to_string(),
480 "bridge connection closed"
481 );
482 assert_eq!(FezError::Aborted.to_string(), "aborted by user");
483 assert_eq!(
484 FezError::NotFound("sshd.service".into()).to_string(),
485 "not found: sshd.service"
486 );
487 assert_eq!(
488 FezError::Protected {
489 unit: "sshd.service".into(),
490 }
491 .to_string(),
492 "refused: sshd.service is a protected unit (use --force to override)"
493 );
494 assert_eq!(
495 FezError::Problem("not-found".into()).to_string(),
496 "channel problem: not-found"
497 );
498 assert_eq!(
499 FezError::Dbus {
500 name: "org.example.Err".into(),
501 message: "bad".into(),
502 }
503 .to_string(),
504 "dbus error org.example.Err: bad"
505 );
506 assert_eq!(
507 FezError::Spawn {
508 program: "p".into(),
509 source: std::io::Error::other("x"),
510 }
511 .to_string(),
512 "failed to spawn p: x"
513 );
514 assert!(FezError::Io(std::io::Error::other("disk"))
515 .to_string()
516 .starts_with("i/o error"));
517 assert!(
518 FezError::Decode(serde_json::from_str::<i32>("x").unwrap_err())
519 .to_string()
520 .starts_with("protocol decode error")
521 );
522 assert_eq!(
523 FezError::DependencyMissing {
524 component: "dnf5daemon".into(),
525 dbus_name: "org.rpm.dnf.v0".into(),
526 remediation: "install it".into(),
527 }
528 .to_string(),
529 "missing dependency dnf5daemon on target: install it"
530 );
531 assert_eq!(
532 FezError::DangerousTransaction {
533 reason: "removes glibc".into(),
534 removed: vec!["glibc".into()],
535 }
536 .to_string(),
537 "refused: dangerous transaction (removes glibc); use --force to override"
538 );
539 }
540}