1use thiserror::Error;
3
4pub type Result<T> = std::result::Result<T, FezError>;
6
7#[derive(Debug, Error)]
9pub enum FezError {
10 #[error("failed to spawn {program}: {source}")]
12 Spawn {
13 program: String,
15 #[source]
17 source: std::io::Error,
18 },
19 #[error("i/o error: {0}")]
21 Io(#[source] std::io::Error),
22 #[error("protocol decode error: {0}")]
24 Decode(#[source] serde_json::Error),
25 #[error("timed out waiting for the bridge")]
27 Timeout,
28 #[error("bridge connection closed")]
30 BridgeClosed,
31 #[error("channel problem: {0}")]
33 Problem(String),
34 #[error("dbus error {name}: {message}")]
36 Dbus {
37 name: String,
39 message: String,
41 },
42 #[error("not found: {0}")]
44 NotFound(String),
45 #[error("refused: {unit} is a protected unit (use --force to override)")]
47 Protected {
48 unit: String,
50 },
51 #[error("aborted by user")]
53 Aborted,
54}
55
56pub struct ExitCodeDoc {
58 pub code: i32,
60 pub label: &'static str,
62 pub meaning: &'static str,
64}
65
66pub 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 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 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}