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")]
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    /// Safe read-only follow-up hints for actionable errors.
232    ///
233    /// Returns a hints block suitable for the `fez/v1` error envelope, or
234    /// `None` for errors without a useful next-step suggestion. The hint is
235    /// derived from the error's own fields so it is correct regardless of which
236    /// capability produced the error.
237    ///
238    /// - `DependencyMissing`: exposes the `remediation` text already carried by
239    ///   the error, so the agent can act without parsing the `message` string.
240    /// - `UnsupportedApi`: tells the caller the feature is absent on this host
241    ///   and should not be retried.
242    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
265/// Whether a D-Bus error name indicates the service is not activatable (the
266/// signal that a required daemon like dnf5daemon is not installed/running).
267pub 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}