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    /// The user declined a confirmation prompt.
52    #[error("aborted by user")]
53    Aborted,
54}
55
56/// One documented exit code in the agent-facing contract.
57pub struct ExitCodeDoc {
58    /// Numeric process exit code.
59    pub code: i32,
60    /// Stable label tying the code to its error category.
61    pub label: &'static str,
62    /// One-line human meaning.
63    pub meaning: &'static str,
64}
65
66/// The agent-facing exit-code contract. `fez guide` renders this; a test
67/// asserts every fatal code `exit_code()` can produce appears here. The
68/// `exit_code()` match below stays the compile-time-exhaustive source for
69/// per-variant mapping.
70pub const EXIT_CODES: &[ExitCodeDoc] = &[
71    ExitCodeDoc {
72        code: 1,
73        label: "general",
74        meaning: "Unclassified failure (I/O, decode, aborted).",
75    },
76    ExitCodeDoc {
77        code: 4,
78        label: "not-found",
79        meaning: "Target resource (e.g. a unit) does not exist.",
80    },
81    ExitCodeDoc {
82        code: 5,
83        label: "timeout",
84        meaning: "The bridge did not respond before the deadline.",
85    },
86    ExitCodeDoc {
87        code: 6,
88        label: "bridge",
89        meaning: "Bridge could not be spawned or the connection closed.",
90    },
91    ExitCodeDoc {
92        code: 7,
93        label: "dbus",
94        meaning: "A D-Bus call returned an error.",
95    },
96    ExitCodeDoc {
97        code: 8,
98        label: "protected-unit",
99        meaning: "Protected unit refused without --force.",
100    },
101];
102
103impl FezError {
104    /// Stable machine-readable error code for this error.
105    pub fn code(&self) -> &'static str {
106        match self {
107            FezError::Spawn { .. } => "bridge-unavailable",
108            FezError::Io(_) => "io-error",
109            FezError::Decode(_) => "protocol-error",
110            FezError::Timeout => "timeout",
111            FezError::BridgeClosed => "bridge-closed",
112            FezError::Problem(p) => problem_code(p),
113            FezError::Dbus { .. } => "dbus-error",
114            FezError::NotFound(_) => "not-found",
115            FezError::Protected { .. } => "protected-unit",
116            FezError::Aborted => "aborted",
117        }
118    }
119    /// Process exit code to use when this error is fatal.
120    pub fn exit_code(&self) -> i32 {
121        match self {
122            FezError::NotFound(_) | FezError::Problem(_) => 4,
123            FezError::Timeout => 5,
124            FezError::Spawn { .. } | FezError::BridgeClosed => 6,
125            FezError::Dbus { .. } => 7,
126            FezError::Protected { .. } => 8,
127            _ => 1,
128        }
129    }
130}
131
132fn problem_code(p: &str) -> &'static str {
133    match p {
134        "not-found" => "not-found",
135        "access-denied" => "access-denied",
136        "authentication-failed" => "auth-failed",
137        "not-supported" => "not-supported",
138        _ => "channel-problem",
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn exit_code_table_documents_every_nonone_code() {
148        use std::collections::HashSet;
149        let documented: HashSet<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
150        let produced = [
151            FezError::NotFound("x".into()).exit_code(),
152            FezError::Timeout.exit_code(),
153            FezError::BridgeClosed.exit_code(),
154            FezError::Dbus {
155                name: "n".into(),
156                message: "m".into(),
157            }
158            .exit_code(),
159            FezError::Protected { unit: "u".into() }.exit_code(),
160        ];
161        for code in produced {
162            if code != 1 {
163                assert!(
164                    documented.contains(&code),
165                    "exit code {code} undocumented in EXIT_CODES"
166                );
167            }
168        }
169    }
170
171    #[test]
172    fn exit_code_table_is_nonempty_and_sorted() {
173        assert!(!EXIT_CODES.is_empty());
174        let codes: Vec<i32> = EXIT_CODES.iter().map(|e| e.code).collect();
175        let mut sorted = codes.clone();
176        sorted.sort_unstable();
177        assert_eq!(codes, sorted, "EXIT_CODES should be ascending by code");
178    }
179
180    #[test]
181    fn maps_problem_to_code() {
182        assert_eq!(FezError::Problem("not-found".into()).code(), "not-found");
183        assert_eq!(FezError::Problem("weird".into()).code(), "channel-problem");
184    }
185
186    #[test]
187    fn maps_exit_codes() {
188        assert_eq!(FezError::NotFound("x".into()).exit_code(), 4);
189        assert_eq!(FezError::Timeout.exit_code(), 5);
190        assert_eq!(FezError::BridgeClosed.exit_code(), 6);
191    }
192
193    #[test]
194    fn protected_maps_code_and_exit() {
195        let e = FezError::Protected {
196            unit: "sshd.service".into(),
197        };
198        assert_eq!(e.code(), "protected-unit");
199        assert_eq!(e.exit_code(), 8);
200    }
201
202    #[test]
203    fn aborted_maps_code_and_exit() {
204        assert_eq!(FezError::Aborted.code(), "aborted");
205        assert_eq!(FezError::Aborted.exit_code(), 1);
206    }
207
208    #[test]
209    fn problem_code_covers_all_known_kinds() {
210        assert_eq!(
211            FezError::Problem("access-denied".into()).code(),
212            "access-denied"
213        );
214        assert_eq!(
215            FezError::Problem("authentication-failed".into()).code(),
216            "auth-failed"
217        );
218        assert_eq!(
219            FezError::Problem("not-supported".into()).code(),
220            "not-supported"
221        );
222    }
223
224    #[test]
225    fn codes_for_spawn_io_decode_dbus() {
226        let spawn = FezError::Spawn {
227            program: "cockpit-bridge".into(),
228            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
229        };
230        assert_eq!(spawn.code(), "bridge-unavailable");
231        assert_eq!(spawn.exit_code(), 6);
232
233        let io = FezError::Io(std::io::Error::other("boom"));
234        assert_eq!(io.code(), "io-error");
235        assert_eq!(io.exit_code(), 1);
236
237        let decode = FezError::Decode(serde_json::from_str::<i32>("nope").unwrap_err());
238        assert_eq!(decode.code(), "protocol-error");
239
240        let dbus = FezError::Dbus {
241            name: "org.example.Err".into(),
242            message: "bad".into(),
243        };
244        assert_eq!(dbus.code(), "dbus-error");
245        assert_eq!(dbus.exit_code(), 7);
246    }
247
248    #[test]
249    fn display_renders_messages() {
250        assert_eq!(
251            FezError::Timeout.to_string(),
252            "timed out waiting for the bridge"
253        );
254        assert_eq!(
255            FezError::BridgeClosed.to_string(),
256            "bridge connection closed"
257        );
258        assert_eq!(FezError::Aborted.to_string(), "aborted by user");
259        assert_eq!(
260            FezError::NotFound("sshd.service".into()).to_string(),
261            "not found: sshd.service"
262        );
263        assert_eq!(
264            FezError::Protected {
265                unit: "sshd.service".into(),
266            }
267            .to_string(),
268            "refused: sshd.service is a protected unit (use --force to override)"
269        );
270        assert_eq!(
271            FezError::Problem("not-found".into()).to_string(),
272            "channel problem: not-found"
273        );
274        assert_eq!(
275            FezError::Dbus {
276                name: "org.example.Err".into(),
277                message: "bad".into(),
278            }
279            .to_string(),
280            "dbus error org.example.Err: bad"
281        );
282        assert_eq!(
283            FezError::Spawn {
284                program: "p".into(),
285                source: std::io::Error::other("x"),
286            }
287            .to_string(),
288            "failed to spawn p: x"
289        );
290        assert!(FezError::Io(std::io::Error::other("disk"))
291            .to_string()
292            .starts_with("i/o error"));
293        assert!(
294            FezError::Decode(serde_json::from_str::<i32>("x").unwrap_err())
295                .to_string()
296                .starts_with("protocol decode error")
297        );
298    }
299}