Skip to main content

fez/
error.rs

1//! Crate-wide error type and its mapping to stable codes and exit statuses.
2use serde_json::{json, Value};
3use thiserror::Error;
4
5/// Convenience alias for results carrying a [`FezError`].
6pub type Result<T> = std::result::Result<T, FezError>;
7
8/// Every error fez can surface to the caller.
9#[derive(Debug, Error)]
10pub enum FezError {
11    /// A child process (the bridge) could not be spawned.
12    #[error("failed to spawn {program}: {source}")]
13    Spawn {
14        /// Program that failed to launch.
15        program: String,
16        /// Underlying OS error.
17        #[source]
18        source: std::io::Error,
19    },
20    /// A generic I/O failure.
21    #[error("i/o error: {0}")]
22    Io(#[source] std::io::Error),
23    /// A protocol message could not be decoded.
24    #[error("protocol decode error: {0}")]
25    Decode(#[source] serde_json::Error),
26    /// The bridge did not respond before the deadline.
27    #[error("timed out waiting for the bridge")]
28    Timeout,
29    /// The bridge connection was closed unexpectedly.
30    #[error("bridge connection closed")]
31    BridgeClosed,
32    /// The bridge reported a problem; the string is a problem kind.
33    #[error("channel problem: {0}")]
34    Problem(String),
35    /// A D-Bus call returned an error.
36    #[error("dbus error {name}: {message}")]
37    Dbus {
38        /// D-Bus error name.
39        name: String,
40        /// D-Bus error message.
41        message: String,
42    },
43    /// The requested resource (e.g. a unit) does not exist.
44    #[error("not found: {0}")]
45    NotFound(String),
46    /// A protected unit was targeted without `--force`.
47    #[error("refused: {unit} is a protected unit (use --force to override)")]
48    Protected {
49        /// The protected unit that was refused.
50        unit: String,
51    },
52    /// A required target dependency (e.g. dnf5daemon) is absent or not activatable.
53    #[error("missing dependency {component} on target: {remediation}")]
54    DependencyMissing {
55        /// Human-facing name of the missing component (e.g. `dnf5daemon`).
56        component: String,
57        /// The D-Bus name that failed to activate (e.g. `org.rpm.dnf.v0`).
58        dbus_name: String,
59        /// Actionable guidance to install/enable the dependency and retry.
60        remediation: String,
61    },
62    /// A resolved transaction was refused by removal guardrails without `--force`.
63    #[error("refused: dangerous transaction ({reason}); use --force to override")]
64    DangerousTransaction {
65        /// Why the transaction was refused (protected package or cascade size).
66        reason: String,
67        /// Package names the resolved plan would remove.
68        removed: Vec<String>,
69    },
70    /// A CLI usage error (clap parse failure: missing/invalid argument or
71    /// unknown flag) surfaced as a `fez/v1` envelope because `--json` was
72    /// requested. The plain-text path keeps clap's own rendering.
73    #[error("usage error: {0}")]
74    Usage(String),
75    /// The user declined a confirmation prompt.
76    #[error("aborted by user")]
77    Aborted,
78    /// Privilege escalation to root failed (the bridge could not become root,
79    /// e.g. sudo requires a password fez does not supply).
80    #[error("access denied: {remediation}")]
81    AccessDenied {
82        /// Actionable guidance to enable privilege escalation and retry.
83        remediation: String,
84    },
85    /// The managed subsystem is present but does not expose a D-Bus method fez
86    /// needs (the call returned `UnknownMethod`), e.g. an older firewalld
87    /// without `getMasquerade`. Distinct from a missing dependency: the service
88    /// is reachable but its API surface is too old.
89    #[error("unsupported API: {0} is not available on the target")]
90    UnsupportedApi(String),
91}
92
93/// One documented exit code in the agent-facing contract.
94pub struct ExitCodeDoc {
95    /// Numeric process exit code.
96    pub code: i32,
97    /// Stable label tying the code to its error category.
98    pub label: &'static str,
99    /// One-line human meaning.
100    pub meaning: &'static str,
101}
102
103/// The agent-facing exit-code contract. `fez guide` renders this; a test
104/// asserts every fatal code `exit_code()` can produce appears here. The
105/// `exit_code()` match below stays the compile-time-exhaustive source for
106/// per-variant mapping.
107pub 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    /// Stable machine-readable error code for this error.
167    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    /// Process exit code to use when this error is fatal.
187    pub fn exit_code(&self) -> i32 {
188        match self {
189            // Match clap's own convention so plain-text and --json usage errors
190            // share exit 2.
191            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    /// Structured `detail` for the `fez/v1` error envelope.
205    ///
206    /// Returns the machine-readable payload for the error variants that carry
207    /// one (`DependencyMissing`, `DangerousTransaction`, `UnsupportedApi`);
208    /// every other variant has no structured detail and yields `None`.
209    /// Capabilities call this when rendering an error envelope so the mapping
210    /// lives in one place.
211    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
242/// Whether a D-Bus error name indicates the service is not activatable (the
243/// signal that a required daemon like dnf5daemon is not installed/running).
244pub 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}