Skip to main content

fez/
error.rs

1//! Crate-wide error type and its mapping to stable codes and exit statuses.
2use thiserror::Error;
3
4/// Convenience alias for results carrying a [`FezError`].
5pub type Result<T> = std::result::Result<T, FezError>;
6
7/// Every error fez can surface to the caller.
8#[derive(Debug, Error)]
9pub enum FezError {
10    /// A child process (the bridge) could not be spawned.
11    #[error("failed to spawn {program}: {source}")]
12    Spawn {
13        /// Program that failed to launch.
14        program: String,
15        /// Underlying OS error.
16        #[source]
17        source: std::io::Error,
18    },
19    /// A generic I/O failure.
20    #[error("i/o error: {0}")]
21    Io(#[source] std::io::Error),
22    /// A protocol message could not be decoded.
23    #[error("protocol decode error: {0}")]
24    Decode(#[source] serde_json::Error),
25    /// The bridge did not respond before the deadline.
26    #[error("timed out waiting for the bridge")]
27    Timeout,
28    /// The bridge connection was closed unexpectedly.
29    #[error("bridge connection closed")]
30    BridgeClosed,
31    /// The bridge reported a problem; the string is a problem kind.
32    #[error("channel problem: {0}")]
33    Problem(String),
34    /// A D-Bus call returned an error.
35    #[error("dbus error {name}: {message}")]
36    Dbus {
37        /// D-Bus error name.
38        name: String,
39        /// D-Bus error message.
40        message: String,
41    },
42    /// The requested resource (e.g. a unit) does not exist.
43    #[error("not found: {0}")]
44    NotFound(String),
45    /// A protected unit was targeted without `--force`.
46    #[error("refused: {unit} is a protected unit (use --force to override)")]
47    Protected {
48        /// The protected unit that was refused.
49        unit: String,
50    },
51    /// A required target dependency (e.g. dnf5daemon) is absent or not activatable.
52    #[error("missing dependency {component} on target: {remediation}")]
53    DependencyMissing {
54        /// Human-facing name of the missing component (e.g. `dnf5daemon`).
55        component: String,
56        /// The D-Bus name that failed to activate (e.g. `org.rpm.dnf.v0`).
57        dbus_name: String,
58        /// Actionable guidance to install/enable the dependency and retry.
59        remediation: String,
60    },
61    /// A resolved transaction was refused by removal guardrails without `--force`.
62    #[error("refused: dangerous transaction ({reason}); use --force to override")]
63    DangerousTransaction {
64        /// Why the transaction was refused (protected package or cascade size).
65        reason: String,
66        /// Package names the resolved plan would remove.
67        removed: Vec<String>,
68    },
69    /// The user declined a confirmation prompt.
70    #[error("aborted by user")]
71    Aborted,
72    /// Privilege escalation to root failed (the bridge could not become root,
73    /// e.g. sudo requires a password fez does not supply).
74    #[error("access denied: {remediation}")]
75    AccessDenied {
76        /// Actionable guidance to enable privilege escalation and retry.
77        remediation: String,
78    },
79}
80
81/// One documented exit code in the agent-facing contract.
82pub struct ExitCodeDoc {
83    /// Numeric process exit code.
84    pub code: i32,
85    /// Stable label tying the code to its error category.
86    pub label: &'static str,
87    /// One-line human meaning.
88    pub meaning: &'static str,
89}
90
91/// The agent-facing exit-code contract. `fez guide` renders this; a test
92/// asserts every fatal code `exit_code()` can produce appears here. The
93/// `exit_code()` match below stays the compile-time-exhaustive source for
94/// per-variant mapping.
95pub 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    /// Stable machine-readable error code for this error.
145    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    /// Process exit code to use when this error is fatal.
163    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
188/// Whether a D-Bus error name indicates the service is not activatable (the
189/// signal that a required daemon like dnf5daemon is not installed/running).
190pub 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}