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")]
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 pub fn hints(&self) -> Option<Value> {
243 match self {
244 FezError::DependencyMissing { remediation, .. } => {
245 Some(json!({ "remediation": remediation }))
246 }
247 FezError::UnsupportedApi(method) => Some(json!({
248 "unsupported": format!("this host does not expose {method}; treat the feature as unsupported"),
249 })),
250 _ => None,
251 }
252 }
253}
254
255fn problem_code(p: &str) -> &'static str {
256 match p {
257 "not-found" => "not-found",
258 "access-denied" => "access-denied",
259 "authentication-failed" => "auth-failed",
260 "not-supported" => "not-supported",
261 _ => "channel-problem",
262 }
263}
264
265pub fn is_service_unknown(name: &str) -> bool {
268 name.contains("ServiceUnknown") || name.contains("NameHasNoOwner")
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn exit_code_table_documents_every_nonone_code() {
277 use std::collections::HashSet;
278 let documented: HashSet<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
279 let produced = [
280 FezError::NotFound("x".into()).exit_code(),
281 FezError::Timeout.exit_code(),
282 FezError::BridgeClosed.exit_code(),
283 FezError::Dbus {
284 name: "n".into(),
285 message: "m".into(),
286 }
287 .exit_code(),
288 FezError::Protected { unit: "u".into() }.exit_code(),
289 FezError::DependencyMissing {
290 component: "c".into(),
291 dbus_name: "n".into(),
292 remediation: "r".into(),
293 }
294 .exit_code(),
295 FezError::DangerousTransaction {
296 reason: "r".into(),
297 removed: vec![],
298 }
299 .exit_code(),
300 FezError::AccessDenied {
301 remediation: "r".into(),
302 }
303 .exit_code(),
304 FezError::Usage("missing <UNIT>".into()).exit_code(),
305 FezError::UnsupportedApi("getMasquerade".into()).exit_code(),
306 ];
307 for code in produced {
308 if code != 1 {
309 assert!(
310 documented.contains(&code),
311 "exit code {code} undocumented in EXIT_CODES"
312 );
313 }
314 }
315 }
316
317 #[test]
318 fn exit_code_table_is_nonempty_and_sorted() {
319 assert!(!EXIT_CODES.is_empty());
320 let codes: Vec<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
321 let mut sorted = codes.clone();
322 sorted.sort_unstable();
323 assert_eq!(codes, sorted, "EXIT_CODES should be ascending by code");
324 }
325
326 #[test]
327 fn maps_problem_to_code() {
328 assert_eq!(FezError::Problem("not-found".into()).code(), "not-found");
329 assert_eq!(FezError::Problem("weird".into()).code(), "channel-problem");
330 }
331
332 #[test]
333 fn maps_exit_codes() {
334 assert_eq!(FezError::NotFound("x".into()).exit_code(), 4);
335 assert_eq!(FezError::Timeout.exit_code(), 5);
336 assert_eq!(FezError::BridgeClosed.exit_code(), 6);
337 }
338
339 #[test]
340 fn protected_maps_code_and_exit() {
341 let e = FezError::Protected {
342 unit: "sshd.service".into(),
343 };
344 assert_eq!(e.code(), "protected-unit");
345 assert_eq!(e.exit_code(), 8);
346 }
347
348 #[test]
349 fn dependency_missing_maps_code_and_exit() {
350 let e = FezError::DependencyMissing {
351 component: "dnf5daemon".into(),
352 dbus_name: "org.rpm.dnf.v0".into(),
353 remediation: "install it".into(),
354 };
355 assert_eq!(e.code(), "dependency-missing");
356 assert_eq!(e.exit_code(), 9);
357 }
358
359 #[test]
360 fn dangerous_transaction_maps_code_and_exit() {
361 let e = FezError::DangerousTransaction {
362 reason: "removes protected package glibc".into(),
363 removed: vec!["glibc".into()],
364 };
365 assert_eq!(e.code(), "dangerous-transaction");
366 assert_eq!(e.exit_code(), 10);
367 }
368
369 #[test]
370 fn is_service_unknown_detects_activation_failure() {
371 assert!(is_service_unknown(
372 "org.freedesktop.DBus.Error.ServiceUnknown"
373 ));
374 assert!(is_service_unknown(
375 "org.freedesktop.DBus.Error.NameHasNoOwner"
376 ));
377 assert!(!is_service_unknown("org.freedesktop.systemd1.NoSuchUnit"));
378 }
379
380 #[test]
381 fn aborted_maps_code_and_exit() {
382 assert_eq!(FezError::Aborted.code(), "aborted");
383 assert_eq!(FezError::Aborted.exit_code(), 1);
384 }
385
386 #[test]
387 fn usage_maps_code_and_exit() {
388 let e = FezError::Usage("missing required argument: <UNIT>".into());
389 assert_eq!(e.code(), "usage");
390 assert_eq!(e.exit_code(), 2);
391 assert!(e.to_string().contains("missing required argument"));
392 }
393
394 #[test]
395 fn unsupported_api_maps_code_and_exit() {
396 let e = FezError::UnsupportedApi("getMasquerade".into());
397 assert_eq!(e.code(), "unsupported-api");
398 assert_eq!(e.exit_code(), 12);
399 assert!(e.to_string().contains("getMasquerade"));
400 }
401
402 #[test]
403 fn detail_carries_dependency_missing_fields() {
404 let e = FezError::DependencyMissing {
405 component: "dnf5daemon".into(),
406 dbus_name: "org.rpm.dnf.v0".into(),
407 remediation: "install it".into(),
408 };
409 let d = e.detail().expect("dependency-missing has detail");
410 assert_eq!(d["component"], "dnf5daemon");
411 assert_eq!(d["dbusName"], "org.rpm.dnf.v0");
412 assert_eq!(d["remediation"], "install it");
413 }
414
415 #[test]
416 fn detail_carries_dangerous_transaction_fields() {
417 let e = FezError::DangerousTransaction {
418 reason: "removes glibc".into(),
419 removed: vec!["glibc".into()],
420 };
421 let d = e.detail().expect("dangerous-transaction has detail");
422 assert_eq!(d["reason"], "removes glibc");
423 assert_eq!(d["removed"], json!(["glibc"]));
424 }
425
426 #[test]
427 fn detail_carries_unsupported_api_method() {
428 let e = FezError::UnsupportedApi("getMasquerade".into());
429 let d = e.detail().expect("unsupported-api has detail");
430 assert_eq!(d["method"], "getMasquerade");
431 }
432
433 #[test]
434 fn detail_is_none_for_variants_without_structured_payload() {
435 assert!(FezError::Timeout.detail().is_none());
436 assert!(FezError::NotFound("x".into()).detail().is_none());
437 assert!(FezError::Protected { unit: "u".into() }.detail().is_none());
438 assert!(FezError::AccessDenied {
439 remediation: "r".into()
440 }
441 .detail()
442 .is_none());
443 }
444
445 #[test]
446 fn access_denied_maps_code_and_exit() {
447 let e = FezError::AccessDenied {
448 remediation: "configure NOPASSWD sudo".into(),
449 };
450 assert_eq!(e.code(), "access-denied");
451 assert_eq!(e.exit_code(), 11);
452 assert!(e.to_string().contains("configure NOPASSWD sudo"));
453 }
454
455 #[test]
456 fn problem_code_covers_all_known_kinds() {
457 assert_eq!(
458 FezError::Problem("access-denied".into()).code(),
459 "access-denied"
460 );
461 assert_eq!(
462 FezError::Problem("authentication-failed".into()).code(),
463 "auth-failed"
464 );
465 assert_eq!(
466 FezError::Problem("not-supported".into()).code(),
467 "not-supported"
468 );
469 }
470
471 #[test]
472 fn codes_for_spawn_io_decode_dbus() {
473 let spawn = FezError::Spawn {
474 program: "cockpit-bridge".into(),
475 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
476 };
477 assert_eq!(spawn.code(), "bridge-unavailable");
478 assert_eq!(spawn.exit_code(), 6);
479
480 let io = FezError::Io(std::io::Error::other("boom"));
481 assert_eq!(io.code(), "io-error");
482 assert_eq!(io.exit_code(), 1);
483
484 let decode = FezError::Decode(serde_json::from_str::<i32>("nope").unwrap_err());
485 assert_eq!(decode.code(), "protocol-error");
486
487 let dbus = FezError::Dbus {
488 name: "org.example.Err".into(),
489 message: "bad".into(),
490 };
491 assert_eq!(dbus.code(), "dbus-error");
492 assert_eq!(dbus.exit_code(), 7);
493 }
494
495 #[test]
496 fn display_renders_messages() {
497 assert_eq!(
498 FezError::Timeout.to_string(),
499 "timed out waiting for the bridge"
500 );
501 assert_eq!(
502 FezError::BridgeClosed.to_string(),
503 "bridge connection closed"
504 );
505 assert_eq!(FezError::Aborted.to_string(), "aborted by user");
506 assert_eq!(
507 FezError::NotFound("sshd.service".into()).to_string(),
508 "not found: sshd.service"
509 );
510 assert_eq!(
511 FezError::Protected {
512 unit: "sshd.service".into(),
513 }
514 .to_string(),
515 "refused: sshd.service is a protected unit (use --force to override)"
516 );
517 assert_eq!(
518 FezError::Problem("not-found".into()).to_string(),
519 "channel problem: not-found"
520 );
521 assert_eq!(
522 FezError::Dbus {
523 name: "org.example.Err".into(),
524 message: "bad".into(),
525 }
526 .to_string(),
527 "dbus error org.example.Err: bad"
528 );
529 assert_eq!(
530 FezError::Spawn {
531 program: "p".into(),
532 source: std::io::Error::other("x"),
533 }
534 .to_string(),
535 "failed to spawn p: x"
536 );
537 assert!(FezError::Io(std::io::Error::other("disk"))
538 .to_string()
539 .starts_with("i/o error"));
540 assert!(
541 FezError::Decode(serde_json::from_str::<i32>("x").unwrap_err())
542 .to_string()
543 .starts_with("protocol decode error")
544 );
545 assert_eq!(
546 FezError::DependencyMissing {
547 component: "dnf5daemon".into(),
548 dbus_name: "org.rpm.dnf.v0".into(),
549 remediation: "install it".into(),
550 }
551 .to_string(),
552 "missing dependency dnf5daemon on target"
553 );
554 assert_eq!(
555 FezError::DangerousTransaction {
556 reason: "removes glibc".into(),
557 removed: vec!["glibc".into()],
558 }
559 .to_string(),
560 "refused: dangerous transaction (removes glibc); use --force to override"
561 );
562 }
563}